diff --git a/.gitignore b/.gitignore index 3e7bf93..841b23b 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ + diff --git a/.workbuddy/memory/2026-07-07.md b/.workbuddy/memory/2026-07-07.md new file mode 100644 index 0000000..c63f26f --- /dev/null +++ b/.workbuddy/memory/2026-07-07.md @@ -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}]` 数组格式设置边框 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 770b791..52e1dd3 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -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表示状态色) diff --git a/AGENTS.md b/AGENTS.md index a6e9ba3..1223dec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## 项目简介 充电柜矩阵系统(Power Matrix System / PMS) -产品名:安知充(anzhizhichong),已有充电站平台在运行,充电柜是新增产品线。 +产品名:安知充(anzhizhichong),品牌名:艾动智能,已有充电站平台在运行,充电柜是新增产品线。 仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。 ## 设备标识 @@ -11,13 +11,13 @@ - **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID) - **设备ID**:4G SIM卡15位IMEI(10进制) - **仓体全局ID**:`平台层ID-仓板ID-仓体ID` -- **扫码链接**:`https://app.anzhizhichong.com/h5/{抽象ID}` +- **扫码入口**:微信小程序(通过小程序码进入充电流程,后端 API 统一前缀 `/api/pms/`) ## 技术栈 - 后端:Rust + Axum - 前端:React + Arco Design Pro(React版) -- H5用户端:uni-app + Vue 3 + Pinia(跨平台,支持 H5 / 微信小程序) +- 微信小程序用户端:uni-app + Vue 3 + Pinia(构建目标 mp-weixin) - 数据库:MySQL(阿里云RDS),开发/生产共用 - 缓存:Redis(阿里云Redis),开发/生产共用 - 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1) @@ -38,12 +38,14 @@ D:\charging-cabinet\ ├── software/ │ ├── api-server/ # Rust API服务 │ ├── device-server/ # Rust TCP设备服务 -│ ├── h5-uniapp/ # H5用户端(uni-app + Vue 3) +│ ├── h5-uniapp/ # 微信小程序用户端(uni-app + Vue 3,构建目标 mp-weixin) │ └── web/ # React前端(WEB管理端) ├── hardware/ # 硬件资料(原理图/PCB等) └── test/ # 测试用例/报告 ``` +> **技能库不入库** — 仓库内不存放任何 skills(`.opencode/skills`、`software/h5-uniapp/.agents/skills`、`software/h5-uniapp/agent/skills` 已于提交 `9829e9d` 移除)。开发期技能统一从全局技能目录加载:`~/.dsh/skills`(如 wechatide-skill)、`~/.agents/skills`(如 anysearch)。请勿在仓库内重新 vendor 技能副本。 + ## 部署架构 ### 环境规划 @@ -53,14 +55,26 @@ D:\charging-cabinet\ | 正式环境 | app.anzhizhichong.com | :9000 | :9001 | db5 | /data/pms | /pms/ | | 测试环境 | test.anzhizhichong.com | :3000 | :3001 | db6 | /data/pms_dev | /pms/ | -**服务端口分配:** -- 测试环境:API 3000,TCP 3001 +**服务端口分配(规划):** - 正式环境:API 9000,TCP 9001 +- 测试环境:API 3000,TCP 3001 + +> ✅ **实际部署现状(2026-09 已按规划完成迁移)**: +> - **正式环境已迁到 API `:9000` / TCP `:9001`**(`/data/pms/*/.env`、systemd 单元、nginx +> `app.anzhizhichong.com.conf` 的 `proxy_pass` 均已改);单元已补 `EnvironmentFile=/data/pms/*/.env` +> (**新二进制强制要求环境变量,缺它起不来**)。 +> - **本服务器不部署测试环境**(用户确认:这台就是生产)—— `pms-api-test` / `pms-device-test` +> 已停止并卸载,`:3000` / `:3001` 已释放。 +> - `test.anzhizhichong.com` 仍服务于静态前端(`/data/pms_dev/web`),但其 `/api/pms/` 因为 +> **后端不在**会返回 **502** —— 这是**刻意的**:此前它指向 `:3000`,而 `:3000` 曾是生产 API, +> 等于**测试后台在操作生产数据**。**不要再把测试域名的 API 指回生产端口。** +> - 如需临时联调环境:`/data/pms_dev/*`(含 `.env`、旧二进制)与 `pms_dev` 库、Redis db6 均**保留**, +> 重新安装 `deploy/pms-*-test.service` 即可恢复(端口用 3000/3001)。 **部署路径:** - 产品介绍页:/data/landing -- 正式环境:/data/pms(api-server、device-server、web、h5) -- 测试环境:/data/pms_dev(api-server、device-server、web、h5) +- 正式环境:/data/pms(api-server、device-server、web) +- 测试环境:/data/pms_dev(api-server、device-server、web) **Nginx配置:** - 正式: /etc/nginx/conf.d/app.anzhizhichong.com.conf @@ -140,17 +154,19 @@ python tools/db.py import data.sql ### 禁止操作 +- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知 - **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制 - **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理 - **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore) ### 编码任务下发 -- 任务需求写到 `tasks/` 目录的md文件 -- 通过Agent子代理包裹opencode执行,异步通知 -- 质量约束模板固定追加到每个任务文件 -- opencode超时必须报告用户,不能fallback手动执行 -- 审查用不同模型(不用开发同款) +- 任务需求写到 `tasks/` 目录的md文件(编号递增,如 036-xxx.md) +- 用户手工将任务交给 opencode 执行(模型:sensenova/glm-5.2,配置在项目根 opencode.json) +- 完成后由 PM(本 agent)验收:cargo check 两服务 → 并行子代理审查代码 → 不通过则写返工任务文件 +- 任务文件必须包含"⛔ 硬性约束"段落(设备协议不可改)和"质量约束"段落 +- 验收不通过时,返工任务需包含具体断路点分析 + 修复方向建议 +- 已完成的任务文件及时清理 ### 质量约束模板 @@ -166,9 +182,10 @@ python tools/db.py import data.sql ### 验收流程 -1. 静态校验命令通过 -2. 跑起来看效果 -3. 抽查关键逻辑(异常处理、权限校验、协议解析) +1. `cargo check` 两个服务(api-server + device-server)零错误 +2. 派并行子代理审查代码(按任务逐项核对修复要求 + 硬性约束) +3. 审查不通过 → 写返工任务文件(含断路点分析、修复方向、具体行号) +4. 审查通过 → 清理任务文件,更新文档 --- @@ -185,16 +202,22 @@ python tools/db.py import data.sql - 使用 `tokio` 运行时,数据库/Redis 操作用 `.await` - 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环 -- Redis 队列消费:`blpop` 阻塞读取 + `tokio::spawn` 并发处理 +- Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** —— + 本项目 Redis 经 nginx stream 代理访问阿里云 RDS,客户端又是多路复用的 `ConnectionManager`, + 这种组合下 `BRPOP` 语义不可靠(实测 2026-09): + - `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**; + - `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。 + **规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致), + 延迟稳定在 200ms 内;取到消息后 `tokio::spawn` 并发处理。 ### 模块划分 ``` src/ ├── routes/ # HTTP 路由处理器(按业务拆分) -├── middleware/ # 中间件(认证、日志、CORS) +├── middleware/ # 中间件(认证、日志、CORS、scope_guard 组织归属校验) ├── db/ # 数据库连接池、迁移 -├── commands/ # 设备指令下发逻辑 +├── commands/ # 设备指令下发逻辑(含 AppState 定义) ├── nodes/ # 设备服务节点管理 ├── workers/ # 后台 Worker(report/reply) └── error.rs # 统一错误定义 @@ -222,12 +245,11 @@ src/ - 页面级状态用 `useState` / `useEffect` - 全局状态用 `zustand`(如用户信息、权限) -- 服务端数据用 `@tanstack/react-query`,配置缓存策略 ### 样式规范 -- 使用 Tailwind CSS,禁止内联 style(除动态计算值) -- shadcn/ui 组件优先,不重复造轮子 +- 使用 Arco Design 组件库,优先使用组件自带的样式 +- 内联 style 仅用于动态计算值,静态样式用 CSS class - 表格列统一 `min-width` + `align="center"`,无 `fixed="right"` - 分页:上下双分页,`page-sizes="[10,20,30,50]"` @@ -318,7 +340,7 @@ src/ 示例: - `feat(cabinets): 添加设备RSSI信号显示` -- `fix(h5): 修复登录后token未持久化问题` +- `fix(mp): 修复登录后token未持久化问题` - `docs(api): 更新充电记录接口文档` --- @@ -356,10 +378,17 @@ test_check_permission_user_returns_forbidden ### 认证授权 -- JWT Token 有效期:7天,过期需重新登录 +- JWT Token 有效期:24小时,过期需重新登录 +- JWT 密钥:启动时从 `JWT_SECRET` 环境变量读取,缺失则 panic(禁止硬编码回退),存储在 AppState.jwt_secret - 密码存储:argon2 哈希,禁止明文或 MD5/SHA1 - RBAC 权限码:精确到按钮级别(如 `device:door:unlock`) +- 组织数据隔离:所有写操作校验资源归属(scope_guard.rs),总管理员(role_level=2)豁免 - 敏感操作(删除/禁用/开门)需二次确认 +- TCP 连接认证:设备 login 成功(响应含 `spec` + `suc=1`)前,敏感指令(open/pow_on/pow_off/reset/auth_str)不投递 +- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`) +- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`** +- **`dev_id` 只在 `auth_str` / `login` 携带**(用于绑定 TCP 连接),其余报文一律不带;设备身份由连接确定 +- **未登录连接不予保持**:建连后 30 秒内未 `login` 成功即主动断开 ### 输入校验 @@ -532,7 +561,7 @@ server { # PMS API 入口(微服务统一前缀 /api/{服务}) location /api/pms/ { - proxy_pass http://127.0.0.1:3000/api/; + proxy_pass http://127.0.0.1:9000/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -545,10 +574,13 @@ server { try_files $uri $uri/ /pms/index.html; } - # H5用户端 - location /h5/ { - alias /data/pms/h5/; - try_files $uri $uri/ /h5/index.html; + # 微信回调(免认证) + location /api/wechat/ { + proxy_pass http://127.0.0.1:9000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } ``` @@ -586,16 +618,13 @@ server { try_files $uri $uri/ /pms/index.html; } - # H5用户端 - location /h5/ { - alias /data/pms_dev/h5/; - try_files $uri $uri/ /h5/index.html; - } - - # H5用户端 - location /h5/ { - alias /data/pms_dev/h5/; - try_files $uri $uri/ /h5/index.html; + # 微信回调(免认证) + location /api/wechat/ { + proxy_pass http://127.0.0.1:3000; + 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; } } ``` @@ -604,55 +633,80 @@ server { ### 部署步骤 +> ✅ **推荐用一键脚本(在本地开发机执行)**: +> ```bash +> pwsh deploy/deploy-prod.ps1 # 完整部署:构建 → 校验产物 → 服务器备份 → 传 .new → 原子替换 → 重启 → 验收 +> pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,只重发现有产物 +> pwsh deploy/deploy-prod.ps1 -DryRun # 演练,不做任何改动 +> pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端 +> ``` +> 脚本会自动做:产物路径校验、**上传后 `chmod +x`**、**传 `.new` 再原子 `mv`**(避开 ETXTBSY)、 +> `nginx -t` 后才 reload、验收(服务/端口/域名/ERROR 数)、以及**用不存在的 IMEI 探测上报消费链路** +> (不碰真实设备数据),最后打印回滚方法。 +> +> ⛔ **`deploy/deploy-prod.sh` 与 `deploy/deploy-test.sh` 已废弃**(执行即报错退出): +> 它们在 `target/release/` 找产物、会安装重复的 `pms-api-prod` 单元、并会用本地 `.env` +> 覆盖线上 `.env`(历史上该文件带行内注释,会让生产起不来)。下面手写步骤仅供参考。 + **1. 本地编译** ```bash -# 后端(Rust) +# 后端(Rust)— Windows 交叉编译 Linux 目标 cd software/api-server -cargo build --release +cargo zigbuild --release --target x86_64-unknown-linux-gnu cd software/device-server -cargo build --release +cargo zigbuild --release --target x86_64-unknown-linux-gnu -# 前端(React) +# 前端(React 管理端) cd software/web npm run build + +# 微信小程序用户端(uni-app) +cd software/h5-uniapp +npm run build:mp-weixin +# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传 ``` **2. 传输到服务器** ```bash # 传输后端二进制(测试环境) -scp software/api-server/target/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/ -scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms_dev/device-server/ +scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/ +scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms_dev/device-server/ # 传输后端二进制(正式环境) -scp software/api-server/target/release/pms-api root@118.31.119.206:/data/pms/api-server/ -scp software/device-server/target/release/device-server root@118.31.119.206:/data/pms/device-server/ +scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/ +scp software/device-server/target/x86_64-unknown-linux-gnu/release/device-server root@118.31.119.206:/data/pms/device-server/ # 传输前端静态文件 scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境 scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境 - -# 传输H5用户端(tar.gz打包方式,保留UTF-8编码) -cd software/h5-uniapp/dist/build/h5 -tar -czf /tmp/h5-build.tar.gz . -pscp -pw "Hbhyg731024@" /tmp/h5-build.tar.gz root@118.31.119.206:/tmp/ -plink -ssh -pw "Hbhyg731024@" root@118.31.119.206 "rm -rf /data/pms/h5/* && cd /data/pms/h5 && tar -xzf /tmp/h5-build.tar.gz" ``` -> ⚠️ H5部署必须用tar.gz打包方式,不要用plink/pscp管道传单文件(会丢失UTF-8编码导致中文变问号)。 +> ⚠️ 后端二进制必须用 `cargo zigbuild --release --target x86_64-unknown-linux-gnu` 交叉编译,服务器 2核/3.5G 会 OOM,禁止在服务器上编译 Rust。 +> +> ⚠️ **`pscp`/`scp` 上传后必须 `chmod +x`**(否则 systemd 报 `203/EXEC`); +> 且**运行中的二进制不可直接覆盖**(Linux 报 `ETXTBSY`)—— 正确姿势: +> 传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。 +> +> ⚠️ **`.env` 不支持行内注释**:`PORT=3000 # 注释` 会让配置解析失败、服务起不来,注释必须独占一行。 + +**2.1 微信小程序上传** + +小程序构建产物 `dist/build/mp-weixin/` 不上传到服务器,而是通过微信开发者工具上传: + +1. 打开微信开发者工具 +2. 导入项目目录 `software/h5-uniapp/dist/build/mp-weixin` +3. 填入 AppID(已配置在 `manifest.json`) +4. 预览/真机调试 → 上传体验版/正式版 **3. 服务器重启服务** ```bash # SSH到服务器 echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@" -# 重启测试环境 -systemctl restart pms-api-test -systemctl restart pms-device-test - -# 重启正式环境 -systemctl restart pms-api-prod -systemctl restart pms-device-prod +# 正式环境 +systemctl restart pms-api +systemctl restart pms-device # 重启Nginx systemctl restart nginx @@ -662,13 +716,13 @@ systemctl restart nginx ```bash # 查看服务状态 -systemctl status pms-api-test # 测试环境API -systemctl status pms-api-prod # 正式环境API +systemctl status pms-api # 正式环境API +systemctl status pms-device # 正式环境TCP systemctl status nginx # 查看日志 -journalctl -u pms-api-test -f -journalctl -u pms-api-prod -f +journalctl -u pms-api -f +journalctl -u pms-device -f tail -f /var/log/nginx/error.log ``` @@ -685,12 +739,11 @@ tail -f /var/log/nginx/error.log ### 做 - 后台管理系统(Web端) -- 用户端H5页面(`/pms/{id}`,微信扫码可访问) +- 微信小程序用户端(uni-app 构建 mp-weixin,通过微信开发者工具上传) - 设备TCP通讯服务(协议文档已就绪) ### 不做 -- 微信小程序(名额暂不可用,先用H5,uni-app可后续转换) - 支付/计费(本系统不涉及) ## 组织架构 @@ -779,26 +832,63 @@ device:{imei} # 设备级 Hash ## TCP通讯协议 -详见 [docs/API.md](docs/API.md#tcp-设备通讯协议) — 包含签名机制、指令清单、指令详情、充电结束原因。 +详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。 ## 数据单位规范 详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。 -**现有浮点问题(需清理):** +> ✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的 `write_number` API 参数为 f64(`i64 as f64` 转换,非业务浮点)。 -| 文件 | 位置 | 问题 | 处理 | -|------|------|------|------| -| `src/routes/charge_records.rs` | L46-47, L77-78 | `Option` 读 DECIMAL | 需 CAST AS DOUBLE [有CAST] | -| `src/routes/energy_stats.rs` | L106, L116 | `query_scalar::<_, f64>` 直接读 DECIMAL 无 CAST ❌ | 需加 CAST AS DOUBLE 或改用整数 | -| `src/routes/energy_stats.rs` | L43-72 | 多个 `Option` 字段 | 待迁移为 i64 | -| `src/db/migrate.rs` | L76-77, L97 | `DECIMAL` 列定义 | 待改为 `BIGINT` | +## 微信小程序用户端 -## H5用户端 +### 小程序配置 + +| 参数 | 值 | +|------|-----| +| AppID | `wx226061c550c52e85` | +| AppSecret | `f11dfee9dc73265b75bf54277ebb1047` | +| 账号 | `13951516272@163.com` | +| 消息加解密方式 | 明文模式 | +| 数据格式 | XML | +| 服务器地址(URL) | `https://app.anzhizhichong.com/api/wechat/callback` | +| Token(令牌) | `7GW16XdnFyak0mE32rc8ZPij` | +| EncodingAESKey | `kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu` | + +> ⚠️ 以上密钥仅记录在内网 Git 仓库 `生产环境.txt`,不入代码仓库。 + +### 构建命令 + +```bash +cd software/h5-uniapp +npm run build:mp-weixin +# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传 +``` + +### API 域名配置 + +小程序请求后端 API 必须配置合法域名(HTTPS): + +- 后台「开发管理 → 开发设置 → 服务器域名」 +- request 合法域名:`https://app.anzhizhichong.com` +- 开发阶段可在 `manifest.json` 设 `urlCheck: false` 跳过校验 + +### API base URL + +小程序用全 URL(`api/index.ts` 条件编译): + +```typescript +// #ifdef MP-WEIXIN +const BASE_URL = 'https://app.anzhizhichong.com/api/pms' +// #endif +// #ifndef MP-WEIXIN +const BASE_URL = '/api/pms' +// #endif +``` ### 信号强度显示(RSSI) -设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和 H5 移动端共用同一套逻辑。 +设备卡片右上角使用 Lucide 信号图标显示 RSSI 信号强度,PC 管理端和小程序共用同一套逻辑。 **RSSI 映射:** @@ -810,7 +900,7 @@ device:{imei} # 设备级 Hash | ≥ 30 | signal-low | 2 格(含 M7) | #f97316 橙色 | | < 30 或 null | signal-zero | 1 格(仅 M2) | #9ca3af 灰色(离线) | -**实现方式(移动端 H5):** +**实现方式(小程序):** ```vue @@ -838,8 +928,8 @@ HSET device:{imei} online 1 rssi 85 **相关文件:** - PC: `software/web/src/pages/devices/CabinetGrid.tsx` — `SignalIcon` 组件 -- H5: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板 -- H5 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline +- 小程序: `software/h5-uniapp/src/pages/home/index.vue` — signalColor 函数 + 模板 +- 小程序 API: `software/api-server/src/routes/h5.rs` — `get_cabinets` 加 Redis pipeline - 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi` ### 近期 UI 改动 @@ -854,3 +944,16 @@ HSET device:{imei} online 1 rssi 85 2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠 3. **内联 SVG + v-if**:Vue 3 支持在 `` 上用 `v-if` 条件渲染,适用于动态图标 4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `` 上写死 + +## 代码审查豁免 + +以下模式已确认无需修改: + +| 审查项 | 原因 | +|--------|------| +| TCP keepalive 配置 | 我们是 TCP server,设备才是 client。设备每 60s 发 status_post 就是应用层保活,不需要在 server 侧设 keepalive。 | +| SHA256 签名只取 8 位 | MCU 硬件协议固定,不可改 | +| AGENTS.md 明文密码 | 内网 Git 仓库,agent 需要直连 | +| 小程序 token 存 Storage | 小程序无 HttpOnly cookie,纯前端场景 | +| 8-char sign 长度 | 硬件约束,设备一次只能获取一次 auth_str | +| ServerResponse / DeviceCommand 含额外内部字段(`_dev_id`、`_close`) | `_dev_id` = 目标设备 IMEI,**供 device-server 路由指令**(协议 `id` 已不含 IMEI,只表示动作目标);`_close` 指示发送后断开连接(`login` 失败时)。两者**下发前均由 device-server 摘除**(`split_internal_flags`),设备侧不可见 | diff --git a/README.md b/README.md index e906b3a..f188222 100644 --- a/README.md +++ b/README.md @@ -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执行,异步通知 diff --git a/deploy/deploy-prod.ps1 b/deploy/deploy-prod.ps1 new file mode 100644 index 0000000..1f1262f --- /dev/null +++ b/deploy/deploy-prod.ps1 @@ -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 root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-/* /data/pms/... && systemctl start pms-api pms-device`"" +Say ' 备份目录见 /data/pms/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份)' diff --git a/deploy/deploy-prod.sh b/deploy/deploy-prod.sh index d172527..73c1448 100644 --- a/deploy/deploy-prod.sh +++ b/deploy/deploy-prod.sh @@ -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/pms-api /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 diff --git a/deploy/deploy-test.sh b/deploy/deploy-test.sh index 5b92aed..c90f15c 100644 --- a/deploy/deploy-test.sh +++ b/deploy/deploy-test.sh @@ -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/pms-api /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 diff --git a/deploy/landing/index.html b/deploy/landing/index.html index 5142e92..4709e68 100644 --- a/deploy/landing/index.html +++ b/deploy/landing/index.html @@ -3,7 +3,7 @@ -安知充 — 智能充电柜矩阵系统 +艾动智能 — 智能充电柜矩阵系统 艾动智能 \ No newline at end of file diff --git a/logo/圆切独立LOGO.png b/logo/圆切独立LOGO.png new file mode 100644 index 0000000..904c176 Binary files /dev/null and b/logo/圆切独立LOGO.png differ diff --git a/logo/圆切独立LOGO.svg b/logo/圆切独立LOGO.svg new file mode 100644 index 0000000..1b433a9 --- /dev/null +++ b/logo/圆切独立LOGO.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logo/白色横向.svg b/logo/白色横向.svg new file mode 100644 index 0000000..29dff74 --- /dev/null +++ b/logo/白色横向.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logo/白色横向@2x.png b/logo/白色横向@2x.png new file mode 100644 index 0000000..56bbfc6 Binary files /dev/null and b/logo/白色横向@2x.png differ diff --git a/logo/白色竖向.svg b/logo/白色竖向.svg new file mode 100644 index 0000000..9c447f0 --- /dev/null +++ b/logo/白色竖向.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logo/白色竖向@2x.png b/logo/白色竖向@2x.png new file mode 100644 index 0000000..f50f5c9 Binary files /dev/null and b/logo/白色竖向@2x.png differ diff --git a/logo/纯色横向.svg b/logo/纯色横向.svg new file mode 100644 index 0000000..b0539cb --- /dev/null +++ b/logo/纯色横向.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logo/纯色横向@2x.png b/logo/纯色横向@2x.png new file mode 100644 index 0000000..3793ff1 Binary files /dev/null and b/logo/纯色横向@2x.png differ diff --git a/logo/纯色竖向.svg b/logo/纯色竖向.svg new file mode 100644 index 0000000..9b0e098 --- /dev/null +++ b/logo/纯色竖向.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/logo/纯色竖向@2x.png b/logo/纯色竖向@2x.png new file mode 100644 index 0000000..7f59d7d Binary files /dev/null and b/logo/纯色竖向@2x.png differ diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..95a950b --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "model": "sensenova/glm-5.2" +} diff --git a/software/api-server/.env.example b/software/api-server/.env.example new file mode 100644 index 0000000..fc4ffee --- /dev/null +++ b/software/api-server/.env.example @@ -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 \ No newline at end of file diff --git a/software/api-server/.env.production b/software/api-server/.env.production deleted file mode 100644 index 56d619e..0000000 --- a/software/api-server/.env.production +++ /dev/null @@ -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 diff --git a/software/api-server/.env.test b/software/api-server/.env.test deleted file mode 100644 index 741f4bf..0000000 --- a/software/api-server/.env.test +++ /dev/null @@ -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 diff --git a/software/api-server/Cargo.lock b/software/api-server/Cargo.lock index f152e3b..d83768d 100644 --- a/software/api-server/Cargo.lock +++ b/software/api-server/Cargo.lock @@ -58,7 +58,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -102,6 +102,7 @@ dependencies = [ "matchit", "memchr", "mime", + "multer", "percent-encoding", "pin-project-lite", "serde_core", @@ -189,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" @@ -217,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" @@ -275,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" @@ -390,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" @@ -434,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" @@ -605,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]] @@ -707,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" @@ -737,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]] @@ -745,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]] @@ -881,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" @@ -891,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" @@ -987,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" @@ -1012,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" @@ -1024,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" @@ -1045,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" @@ -1075,7 +1234,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -1158,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", ] @@ -1232,17 +1391,22 @@ version = "0.1.0" dependencies = [ "argon2", "axum", + "base64", "bytes", "chrono", "dotenvy", "futures", "hex", + "image", "jsonwebtoken", - "rand", + "md5", + "rand 0.8.6", "redis", + "reqwest", "rust_xlsxwriter", "serde", "serde_json", + "sha1", "sha2", "sqlx", "thiserror", @@ -1251,9 +1415,23 @@ dependencies = [ "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" diff --git a/software/api-server/Cargo.toml b/software/api-server/Cargo.toml index f680314..c42490f 100644 --- a/software/api-server/Cargo.toml +++ b/software/api-server/Cargo.toml @@ -4,7 +4,7 @@ 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"] } diff --git a/software/api-server/src/captcha.rs b/software/api-server/src/captcha.rs new file mode 100644 index 0000000..ce3c9ea --- /dev/null +++ b/software/api-server/src/captcha.rs @@ -0,0 +1,265 @@ +//! 滑块验证码生成与校验 +//! +//! 参考 go-captcha 视觉风格: +//! - 渐变背景(双色渐变) +//! - 丰富纹理(线条+圆点) +//! - 拼图块带投影 +//! - 缺口带白色描边 + +use base64::Engine; +use image::{Rgba, RgbaImage}; +use rand::Rng; +use std::io::Cursor; + +use crate::error::AppError; + +const WIDTH: u32 = 300; +const HEIGHT: u32 = 150; +const PIECE_SIZE: i32 = 44; +const BUMP_RADIUS: i32 = 7; +const TOLERANCE: i32 = 5; +const PIECE_WIDTH: i32 = PIECE_SIZE + BUMP_RADIUS; +const CHALLENGE_TTL: u32 = 300; +const SHADOW_OFFSET: i32 = 3; + +pub(crate) struct CaptchaData { + pub(crate) challenge_id: String, + pub(crate) bg_image: String, + pub(crate) piece_image: String, +} + +pub(crate) async fn create_challenge(redis_url: &str) -> Result { + 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 { + 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 = redis::cmd("GETDEL").arg(&key).query_async(&mut conn).await.map_err(|e| { + tracing::error!("[captcha] Redis 查询失败: {}", e); + AppError::Internal("服务临时不可用".into()) + })?; + + match val { + Some(s) => { + let correct_x: i32 = s.parse().unwrap_or(i32::MAX); + let ok = (user_x - correct_x).abs() <= TOLERANCE; + tracing::debug!("[captcha] 校验 cid={} user_x={} correct_x={} ok={}", challenge_id, user_x, correct_x, ok); + Ok(ok) + } + None => Ok(false), + } +} + +fn generate_images() -> (RgbaImage, RgbaImage, i32) { + let mut rng = rand::thread_rng(); + + // 1. 渐变背景 + let c1 = [rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, 255]; + let c2 = [rng.r#gen::() % 180 + 50, rng.r#gen::() % 180 + 50, rng.r#gen::() % 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::(), rng.r#gen::(), rng.r#gen::(), 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::(), rng.r#gen::(), rng.r#gen::(), rng.gen_range(20..80u8)]); + fill_circle(&mut bg, cx, cy, radius, color); + } + + // 4. 拼图块位置 + let piece_y = (HEIGHT as i32 - PIECE_SIZE) / 2; + let piece_x = rng.gen_range(50..(WIDTH as i32 - PIECE_SIZE - 50)) as u32; + + // 5. 提取拼图块像素 + let mut raw = RgbaImage::new(PIECE_WIDTH as u32, PIECE_SIZE as u32); + for p in raw.pixels_mut() { *p = Rgba([0, 0, 0, 0]); } + for py in 0..PIECE_SIZE { + for px in 0..PIECE_WIDTH { + if is_in_piece(px, py) { + raw.put_pixel(px as u32, py as u32, *bg.get_pixel((piece_x as i32 + px) as u32, (piece_y + py) as u32)); + } + } + } + + // 6. 拼图块加投影(图片尺寸 = 原尺寸 + 投影偏移) + let sw = (PIECE_WIDTH + SHADOW_OFFSET) as u32; + let sh = (PIECE_SIZE + SHADOW_OFFSET) as u32; + let mut piece = RgbaImage::new(sw, sh); + for p in piece.pixels_mut() { *p = Rgba([0, 0, 0, 0]); } + // 先画阴影 + for py in 0..PIECE_SIZE { + for px in 0..PIECE_WIDTH { + if is_in_piece(px, py) { + let sx = px + SHADOW_OFFSET; + let sy = py + SHADOW_OFFSET; + if sx < sw as i32 && sy < sh as i32 { + let d = piece.get_pixel(sx as u32, sy as u32); + piece.put_pixel(sx as u32, sy as u32, alpha_blend(*d, Rgba([0, 0, 0, 80]))); + } + } + } + } + // 再画拼图 + for py in 0..PIECE_SIZE { + for px in 0..PIECE_WIDTH { + let p = raw.get_pixel(px as u32, py as u32); + if p[3] > 0 { + piece.put_pixel(px as u32, py as u32, *p); + } + } + } + + // 7. 背景画缺口 + draw_hole(&mut bg, piece_x as i32, piece_y); + + (bg, piece, piece_x as i32) +} + +fn is_in_piece(px: i32, py: i32) -> bool { + if px >= 0 && px < PIECE_SIZE && py >= 0 && py < PIECE_SIZE { return true; } + let bcx = PIECE_SIZE; + let bcy = PIECE_SIZE / 2; + let dx = px - bcx; + let dy = py - bcy; + dx >= 0 && dx * dx + dy * dy <= BUMP_RADIUS * BUMP_RADIUS +} + +fn draw_hole(img: &mut RgbaImage, hx: i32, hy: i32) { + for y in 0..HEIGHT as i32 { + for x in 0..WIDTH as i32 { + let lx = x - hx; + let ly = y - hy; + if is_in_piece(lx, ly) { + let edge = (lx >= 0 && lx < 2) || (lx >= PIECE_WIDTH - 3 && lx < PIECE_WIDTH) + || (ly >= 0 && ly < 2) || (ly >= PIECE_SIZE - 3 && ly < PIECE_SIZE) + || { + let bcx = PIECE_SIZE; + let bcy = PIECE_SIZE / 2; + let dx = lx - bcx; + let dy = ly - bcy; + dx >= 0 && (dx * dx + dy * dy - BUMP_RADIUS * BUMP_RADIUS).abs() <= 20 + }; + if edge { + img.put_pixel(x as u32, y as u32, Rgba([255, 255, 255, 220])); + } else { + let o = img.get_pixel(x as u32, y as u32); + img.put_pixel(x as u32, y as u32, Rgba([ + (o[0] as u16 * 2 / 3 + 85) as u8, + (o[1] as u16 * 2 / 3 + 85) as u8, + (o[2] as u16 * 2 / 3 + 85) as u8, 255, + ])); + } + } + } + } +} + +fn fill_circle(img: &mut RgbaImage, cx: u32, cy: u32, r: u32, color: Rgba) { + 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) { + 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, src: Rgba) -> Rgba { + 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) +} diff --git a/software/api-server/src/commands.rs b/software/api-server/src/commands.rs index fb35499..b8a30c1 100644 --- a/software/api-server/src/commands.rs +++ b/software/api-server/src/commands.rs @@ -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 = 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, } /// 指令下发请求体 @@ -203,39 +205,46 @@ pub struct CommandRequest { pub act: String, #[serde(default)] pub msg_id: u32, - pub sub_device_id: Option, #[serde(flatten)] pub params: Option, } +/// 内部路由字段名:推入 `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, - #[serde(skip_serializing_if = "Option::is_none")] - pub sub_device_id: Option, - /// 扩展参数(开门指令、充电参数等) + pub id: Option, + /// 扩展参数(充电参数等) #[serde(flatten)] pub params: Option, } -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, 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, }; diff --git a/software/api-server/src/config.rs b/software/api-server/src/config.rs index c6327dd..a87d969 100644 --- a/software/api-server/src/config.rs +++ b/software/api-server/src/config.rs @@ -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 环境变量"); - } - } } diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index e0b237c..3aa9b35 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -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, @@ -74,7 +126,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { start_time TIMESTAMP NULL COMMENT '充电开始时间', end_time TIMESTAMP NULL COMMENT '充电结束时间', energy BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)', - max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(0.1°C)', + max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)', status TINYINT DEFAULT 0 COMMENT '0正常 1异常 2手动停止', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (compartment_id) REFERENCES compartments(id), @@ -174,10 +226,22 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> { created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 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` 解码会 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 = 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", "开门"), @@ -303,12 +530,12 @@ 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?; @@ -350,7 +577,7 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { "charge:start", "charge:stop", "charge:open_door", "charge_record:view", "charge_record:export", "device_log:view", "energy:view", "energy:export", - "user:view", "user:edit", + "user:view", "user:create", "user:edit", "operation_log:view", // 组织管理 "org:view", "org:create", "org:edit", "org:delete", @@ -379,7 +606,7 @@ async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> { } } - // 为普通用户分配查看和操作权限 + // 为操作员分配查看和操作权限 let normal_id: Option<(i64,)> = sqlx::query_as( "SELECT id FROM roles WHERE name = 'normal_user'" ) diff --git a/software/api-server/src/main.rs b/software/api-server/src/main.rs index 8b635b7..190ea24 100644 --- a/software/api-server/src/main.rs +++ b/software/api-server/src/main.rs @@ -3,6 +3,7 @@ //! 提供 HTTP API(后台管理 + H5),处理后端业务逻辑。 //! TCP 设备通信由独立的 device-server 处理,通过 Redis 队列通信。 +mod captcha; mod commands; mod config; mod db; @@ -15,6 +16,7 @@ mod routes; mod workers; use std::net::SocketAddr; +use std::sync::Arc; use tracing_subscriber::EnvFilter; use commands::{AppState, NodeRegistry, PendingCommands}; @@ -28,7 +30,6 @@ async fn main() { // 加载配置 let cfg = config::Config::from_env(); - cfg.validate(); tracing::info!("配置加载完成"); // 初始化 MySQL 连接池 @@ -49,6 +50,11 @@ async fn main() { .expect("Redis 连接失败"); tracing::info!("Redis 连接成功"); + // 读取 JWT 签名密钥(缺失则 panic,禁止硬编码回退) + let jwt_secret: Arc = std::env::var("JWT_SECRET") + .expect("JWT_SECRET environment variable is required") + .into(); + // 初始化共享状态 let pending_commands = PendingCommands::new(); let nodes = NodeRegistry::new(redis_conn.clone()); @@ -58,6 +64,7 @@ async fn main() { pending_commands: pending_commands.clone(), nodes: nodes.clone(), redis_url: cfg.redis_url.clone(), + jwt_secret, }; // 启动后台协程 — 处理设备上报 @@ -80,6 +87,19 @@ async fn main() { workers::node_checker::run(checker_nodes, heartbeat_timeout).await; }); + // 启动后台协程 — 订单超时兜底(status=0 且超过 10 小时自动结束,走统一结算含电量与充电记录) + let timeout_mysql = db_pool.clone(); + let timeout_redis = redis_conn.clone(); + tokio::spawn(async move { + workers::order_timeout::start(timeout_mysql, timeout_redis).await; + }); + + // 启动后台协程 — 设备日志保留策略(device_logs 只增不减,需定期清理,否则撑满 RDS) + let retention_mysql = db_pool.clone(); + tokio::spawn(async move { + workers::log_retention::start(retention_mysql).await; + }); + // 构建 HTTP 路由 let app = routes::build(state); diff --git a/software/api-server/src/middleware/auth.rs b/software/api-server/src/middleware/auth.rs index 2e97757..d76935c 100644 --- a/software/api-server/src/middleware/auth.rs +++ b/software/api-server/src/middleware/auth.rs @@ -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, - /// 角色等级:0普通 1企业管理员 2总管理员 + /// 角色等级:0操作员 1企业管理员 2总管理员 pub role_level: i32, /// 所属组织ID(总管理员为 None) pub organization_id: Option, @@ -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 { - 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 { 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 { 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(), "无效哈希应返回错误"); + } +} diff --git a/software/api-server/src/protocol.rs b/software/api-server/src/protocol.rs index 972adff..a296644 100644 --- a/software/api-server/src/protocol.rs +++ b/software/api-server/src/protocol.rs @@ -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, - pub msg_id: Option, - /// 登录签名时的时间戳(秒级) - pub timestamp: Option, - /// SHA256 签名尾巴 7 位 - pub sign: Option, - /// 485 子设备 ID(仓控板 ID 1-6) - pub sub_device_id: Option, - /// 状态上报时的 HEX 数据 - pub status: Option, - /// 4G 信号强度 - pub rssi: Option, - /// 未在顶层定义的扩展字段 - #[serde(flatten)] - pub extra: Option, -} +// 说明:**上行报文的权威结构定义在 `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, @@ -41,17 +28,52 @@ pub struct ServerResponse { pub err: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auth_str: Option, + /// login 成功时返回:柜子规格,固定 4 位(前 2 位仓控板数 + 后 2 位每板通道数) + /// 例 `"0606"` = 6 块仓控板 × 每板 6 仓(取代旧的 `cell`) + #[serde(skip_serializing_if = "Option::is_none")] + pub spec: Option, + /// login 成功时返回:充电工作模式 `"0"`=平台指令模式 / `"1"`=自动充电工作模式 + #[serde(skip_serializing_if = "Option::is_none")] + pub work_mode: Option, } impl ServerResponse { - /// 创建成功响应。 - pub fn success(msg_id: Option) -> Self { - Self { suc: 1, msg_id, err: None, auth_str: None } + /// 创建成功响应。`act` 必须与请求一致。 + pub fn success(act: impl Into, msg_id: Option) -> 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, err: impl Into) -> Self { - Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None } + /// 创建失败响应(`err` 必须传英文短语)。`act` 必须与请求一致。 + pub fn error(act: impl Into, msg_id: Option, err: impl Into) -> 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) -> 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 { + req.get("msg_id").and_then(|v| v.as_u64()).map(|v| v as u32) +} diff --git a/software/api-server/src/routes/auth.rs b/software/api-server/src/routes/auth.rs index 52723d5..70e6e79 100644 --- a/software/api-server/src/routes/auth.rs +++ b/software/api-server/src/routes/auth.rs @@ -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, +} + +/// GET /api/auth/challenge — 获取登录滑块验证码 +/// +/// 返回背景图(带缺口)和拼图块图,前端拖动对齐后提交偏移量。 +pub async fn get_challenge( + State(state): State, +) -> Result, 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,6 +66,16 @@ pub async fn login( return Err(AppError::BadRequest("手机号和密码不能为空".into())); } + // 校验滑块验证码(防暴力破解 + 人机验证) + if input.challenge_id.is_empty() { + return Err(AppError::BadRequest("缺少登录验证参数".into())); + } + let captcha_x = input.captcha_x.unwrap_or(i32::MAX); + let valid = captcha::verify_offset(&state.redis_url, &input.challenge_id, captcha_x).await?; + if !valid { + return Err(AppError::BadRequest("验证码错误,请刷新页面重试".into())); + } + // 查询用户(含角色和组织信息) let user_row: Option<(i64, Option, String, String, i32, i32, Option)> = sqlx::query_as( "SELECT id, name, phone, password, status, role, organization_id FROM users WHERE phone = ?", @@ -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?; @@ -92,14 +137,14 @@ pub async fn me( ) -> Result, AppError> { let pool = &state.mysql; // 查询用户详细信息 - let row: Option<(i64, Option, String, i32, Option)> = sqlx::query_as( - "SELECT id, name, phone, role, organization_id FROM users WHERE id = ?" + let row: Option<(i64, Option, String, i32, Option, Option)> = 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 = user.permissions.into_iter().collect(); @@ -112,8 +157,291 @@ 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, + /// 会话密钥:预留给敏感数据解密(如 encryptedData/水印校验),当前登录流程不需要 + #[serde(default)] + session_key: Option, + /// 微信错误码:预留用于后续按错误码细分排障(当前仅用 errmsg 文案) + #[serde(default)] + errcode: Option, + #[serde(default)] + errmsg: Option, +} + +/// 微信 access_token 响应 +/// +/// 字段与微信开放平台返回体一一对齐(当前仅读取 access_token/errmsg,其余为预留能力), +/// 故整体允许 dead_code。 +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct TokenResp { + #[serde(default)] + access_token: Option, + /// 有效期秒数:预留用于 access_token 本地缓存(当前每次登录都重新获取) + #[serde(default)] + expires_in: Option, + /// 微信错误码:预留用于后续按错误码细分排障 + #[serde(default)] + errcode: Option, + #[serde(default)] + errmsg: Option, +} + +/// 微信 getuserphonenumber 响应 +/// +/// 字段与微信开放平台返回体一一对齐(当前仅读取 phone_info/errmsg),故整体允许 dead_code。 +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct PhoneNumberResp { + /// 微信错误码:预留用于后续按错误码细分排障 + #[serde(default)] + errcode: Option, + #[serde(default)] + errmsg: Option, + #[serde(default)] + phone_info: Option, +} + +/// 微信手机号信息 +/// +/// 当前业务用 pure_phone_number 匹配用户;带国码的 phone_number 与 country_code +/// 预留用于国际号段/归属地展示,故整体允许 dead_code。 +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[allow(dead_code)] +struct PhoneInfo { + /// 带国码手机号(如 8613951516272),预留国际号段场景 + #[serde(default)] + phone_number: String, + #[serde(default)] + pure_phone_number: String, + /// 国家码(如 86),预留号段归属展示 + #[serde(default)] + country_code: String, +} + +/// POST /api/auth/wx-login — 微信小程序手机号快速登录 +/// +/// 前端先调用 wx.login() 获取 code,再通过 getPhoneNumber 获取 phone_code, +/// 后端向微信服务器换取 openid 和手机号,匹配用户后签发 JWT。 +pub async fn wx_login( + State(state): State, + Json(input): Json, +) -> Result, AppError> { + let appid = std::env::var("WECHAT_APPID") + .map_err(|_| AppError::Internal("WECHAT_APPID 未配置".into()))?; + let secret = std::env::var("WECHAT_APPSECRET") + .map_err(|_| AppError::Internal("WECHAT_APPSECRET 未配置".into()))?; + + let client = Client::new(); + + // 1. 用 code 换取 openid + let jscode_url = format!( + "https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code", + appid, secret, input.code + ); + let js_resp: JsCode2SessionResp = client.get(&jscode_url).send().await + .map_err(|e| AppError::Internal(format!("jscode2session 请求失败: {}", e)))? + .json().await + .map_err(|e| AppError::Internal(format!("jscode2session 解析失败: {}", e)))?; + + let openid = js_resp.openid.ok_or_else(|| { + let err = js_resp.errmsg.unwrap_or_default(); + AppError::Internal(format!("jscode2session 失败: {}", err)) + })?; + + // 2. 获取 access_token + let token_url = format!( + "https://api.weixin.qq.com/cgi-bin/token?appid={}&secret={}&grant_type=client_credential", + appid, secret + ); + let token_resp: TokenResp = client.get(&token_url).send().await + .map_err(|e| AppError::Internal(format!("token 请求失败: {}", e)))? + .json().await + .map_err(|e| AppError::Internal(format!("token 解析失败: {}", e)))?; + + let access_token = token_resp.access_token.ok_or_else(|| { + let err = token_resp.errmsg.unwrap_or_default(); + AppError::Internal(format!("获取 access_token 失败: {}", err)) + })?; + + // 3. 用 phone_code 换取手机号 + let phone_url = format!( + "https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={}", + access_token + ); + let phone_resp: PhoneNumberResp = client.post(&phone_url) + .json(&json!({ "code": input.phone_code })) + .send().await + .map_err(|e| AppError::Internal(format!("getuserphonenumber 请求失败: {}", e)))? + .json().await + .map_err(|e| AppError::Internal(format!("getuserphonenumber 解析失败: {}", e)))?; + + let phone = phone_resp.phone_info + .ok_or_else(|| { + let err = phone_resp.errmsg.unwrap_or_default(); + AppError::Internal(format!("获取手机号失败: {}", err)) + })?; + let phone_number = phone.pure_phone_number; + + // 4. 按手机号查找用户(手机号是唯一标识) + tracing::info!(phone_number = %phone_number, "微信登录: 获取到手机号,开始匹配用户"); + let user_row = sqlx::query_as::<_, (i64, Option, String, i32, i32, Option)>( + "SELECT id, name, phone, status, role, organization_id FROM users WHERE phone = ?", + ) + .bind(&phone_number) + .fetch_optional(&state.mysql) + .await?; + + let (id, name, _phone, status, role_level, organization_id) = + user_row.ok_or_else(|| { + tracing::warn!(phone_number = %phone_number, "微信登录: 手机号未匹配到用户"); + AppError::Unauthorized("用户不存在,请联系管理员添加".into()) + })?; + + // 5. 存 openid(供后续微信接口调用,如订阅消息/获取用户信息) + sqlx::query("UPDATE users SET openid = ? WHERE id = ?") + .bind(&openid) + .bind(id) + .execute(&state.mysql) + .await?; + + if status == 0 { + return Err(AppError::Forbidden("账户已被禁用".into())); + } + + let token = auth::generate_token(id, &_phone, &state.jwt_secret)?; + let permissions = auth::load_user_permissions(&state.mysql, id).await?; + let perm_list: Vec = 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, + Json(input): Json, +) -> Result, 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, + mut multipart: Multipart, +) -> Result, AppError> { + let mut file_data: Option> = 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" + }))) +} diff --git a/software/api-server/src/routes/batteries.rs b/software/api-server/src/routes/batteries.rs new file mode 100644 index 0000000..0feece2 --- /dev/null +++ b/software/api-server/src/routes/batteries.rs @@ -0,0 +1,160 @@ +//! 电池台账 API — 列表查询 + 详情 + +use axum::extract::{Query, State}; +use axum::Json; +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +use crate::error::AppError; +use crate::middleware::auth::{self, CurrentUser}; + +fn status_text(status: i8) -> &'static str { + match status { + 0 => "空闲", + 1 => "使用中", + 2 => "故障", + 3 => "报废", + _ => "未知", + } +} + +#[derive(Debug, Deserialize)] +pub struct BatteryQuery { + pub status: Option, + pub battery_id: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Serialize)] +pub struct BatteryItem { + pub id: i64, + pub battery_id: String, + pub soc: i64, + pub brand: Option, + pub model: Option, + pub sn: Option, + pub status: i8, + pub status_text: String, + pub created_at: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct BatteryRow { + id: i64, + battery_id: String, + soc: i64, + brand: Option, + model: Option, + sn: Option, + status: i8, + created_at: Option, +} + +/// GET /api/batteries — 电池台账列表 +pub async fn list_batteries( + user: CurrentUser, + State(state): State, + Query(params): Query, +) -> Result, AppError> { + auth::check_permission(&user, "device:view")?; + + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(20).min(100); + let offset = (page - 1) * page_size; + + let mut query = "SELECT * FROM batteries WHERE 1=1".to_string(); + let mut count_query = "SELECT COUNT(*) FROM batteries WHERE 1=1".to_string(); + + // 仅判断筛选条件是否存在以拼接 SQL,具体取值在下方绑定 + if params.status.is_some() { + query.push_str(" AND status = ?"); + count_query.push_str(" AND status = ?"); + } + if params.battery_id.is_some() { + query.push_str(" AND battery_id LIKE ?"); + count_query.push_str(" AND battery_id LIKE ?"); + } + + query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); + + let total: i64 = if let Some(s) = params.status { + if let Some(ref bid) = params.battery_id { + sqlx::query_scalar(&count_query) + .bind(s) + .bind(format!("%{}%", bid)) + .fetch_one(&state.mysql) + .await? + } else { + sqlx::query_scalar(&count_query) + .bind(s) + .fetch_one(&state.mysql) + .await? + } + } else if let Some(ref bid) = params.battery_id { + sqlx::query_scalar(&count_query) + .bind(format!("%{}%", bid)) + .fetch_one(&state.mysql) + .await? + } else { + sqlx::query_scalar(&count_query) + .fetch_one(&state.mysql) + .await? + }; + + let rows: Vec = if let Some(s) = params.status { + if let Some(ref bid) = params.battery_id { + sqlx::query_as(&query) + .bind(s) + .bind(format!("%{}%", bid)) + .bind(page_size) + .bind(offset) + .fetch_all(&state.mysql) + .await? + } else { + sqlx::query_as(&query) + .bind(s) + .bind(page_size) + .bind(offset) + .fetch_all(&state.mysql) + .await? + } + } else if let Some(ref bid) = params.battery_id { + sqlx::query_as(&query) + .bind(format!("%{}%", bid)) + .bind(page_size) + .bind(offset) + .fetch_all(&state.mysql) + .await? + } else { + sqlx::query_as(&query) + .bind(page_size) + .bind(offset) + .fetch_all(&state.mysql) + .await? + }; + + let items: Vec = rows.into_iter().map(|r| BatteryItem { + id: r.id, + battery_id: r.battery_id, + soc: r.soc, + brand: r.brand, + model: r.model, + sn: r.sn, + status: r.status, + status_text: status_text(r.status).to_string(), + created_at: r.created_at, + }).collect(); + + Ok(Json(json!({ + "code": 0, + "data": { + "list": items, + "total": total, + "page": page, + "page_size": page_size + }, + "message": "ok" + }))) +} diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs index 44a919e..c7b83e5 100644 --- a/software/api-server/src/routes/cabinet_tree.rs +++ b/software/api-server/src/routes/cabinet_tree.rs @@ -24,6 +24,7 @@ struct CabinetSqlRow { iccid: Option, name: Option, address: Option, + project_address: Option, status: i8, board_count: Option, channel_count: Option, @@ -43,6 +44,7 @@ impl From 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,6 +56,7 @@ impl From for CabinetRow { pow_fail_dc: None, pow_fail_ac: None, is_online: None, + org_disabled: None, } } } @@ -69,14 +72,14 @@ pub async fn organization_tree( // 获取所有组织 let orgs: Vec = sqlx::query_as( - "SELECT id, name, parent_id FROM organizations ORDER BY id" + "SELECT id, name, parent_id, disabled FROM organizations ORDER BY id" ) .fetch_all(db) .await?; // 获取所有项目 let projects: Vec = sqlx::query_as( - "SELECT id, organization_id, name FROM projects ORDER BY id" + "SELECT id, organization_id, name, address, CAST(latitude AS CHAR) AS latitude, CAST(longitude AS CHAR) AS longitude FROM projects ORDER BY id" ) .fetch_all(db) .await?; @@ -116,6 +119,10 @@ pub async fn organization_tree( abstract_id: None, imei: None, status: None, + disabled: None, + address: p.address.clone(), + latitude: p.latitude.clone(), + longitude: p.longitude.clone(), } }) .collect(); @@ -143,6 +150,10 @@ pub async fn organization_tree( abstract_id: None, imei: None, status: None, + disabled: org.disabled, + address: None, + latitude: None, + longitude: None, } }) .collect() @@ -150,7 +161,7 @@ pub async fn organization_tree( let tree = build_tree(&orgs, &projects, &project_count_map, None); - Ok(Json(json!(tree))) + Ok(Json(json!({ "code": 0, "data": tree, "message": "ok" }))) } /// 设备列表筛选参数 @@ -172,7 +183,6 @@ pub async fn filter_cabinets( auth::check_permission(&user, "device:view")?; 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); @@ -181,7 +191,9 @@ pub async fn filter_cabinets( // 基础SQL:联表查询获取通道状态统计 let base_sql = " SELECT - c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, c.status, + c.id, c.project_id, c.abstract_id, c.imei, c.iccid, c.name, c.address, + (SELECT address FROM projects WHERE id = c.project_id) as project_address, + c.status, (SELECT COUNT(*) FROM cabin_boards cb WHERE cb.cabinet_id = c.id) as board_count, (SELECT COUNT(*) FROM cabin_boards cb2 JOIN compartments comp ON comp.cabin_board_id = cb2.id @@ -217,21 +229,21 @@ 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(); + where_clause = "WHERE c.project_id IS NOT NULL".into(); } else { - where_clause.push_str(" AND c.abstract_id IS NOT NULL"); + where_clause.push_str(" AND c.project_id IS NOT NULL"); } } "0" => { if where_clause.is_empty() { - where_clause = "WHERE c.abstract_id IS NULL".into(); + where_clause = "WHERE c.project_id IS NULL".into(); } else { - where_clause.push_str(" AND c.abstract_id IS NULL"); + where_clause.push_str(" AND c.project_id IS NULL"); } } _ => {} @@ -247,7 +259,7 @@ pub async fn filter_cabinets( let total: i64 = count_query.fetch_one(db).await.unwrap_or(0); if total == 0 || offset >= total { - return Ok(Json(json!({"total": 0, "data": []}))); + return Ok(Json(json!({ "code": 0, "data": { "total": 0, "data": [] }, "message": "ok" }))); } // 分页数据 @@ -269,7 +281,8 @@ pub async fn filter_cabinets( let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { AppError::Internal(format!("Redis客户端创建失败: {}", e)) })?; - let mut conn = client.get_async_connection().await.map_err(|e| { + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { AppError::Internal(format!("Redis连接失败: {}", e)) })?; @@ -296,7 +309,34 @@ pub async fn filter_cabinets( } } - Ok(Json(json!({"total": total, "data": rows}))) + // 查询柜子所属组织是否禁用(甲方禁用客户组织 → 前端显示徽标) + { + let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); + if !project_ids.is_empty() { + let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + r#"SELECT p.id, o.disabled FROM projects p + LEFT JOIN organizations o ON p.organization_id = o.id + WHERE p.id IN ({})"#, + placeholders.join(",") + ); + let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); + for pid in &project_ids { + q = q.bind(pid); + } + if let Ok(results) = q.fetch_all(db).await { + let disabled_map: std::collections::HashMap = results + .into_iter() + .map(|(pid, d)| (pid, d == 1)) + .collect(); + for row in rows.iter_mut() { + row.org_disabled = row.project_id.and_then(|pid| disabled_map.get(&pid)).copied(); + } + } + } + } + + Ok(Json(json!({ "code": 0, "data": { "total": total, "data": rows }, "message": "ok" }))) } /// 设备实时状态参数 @@ -313,10 +353,32 @@ pub async fn device_realtime_status( ) -> Result, AppError> { auth::check_permission(&user, "device:view")?; + // 校验设备归属 + if user.role_level < 2 { + if let Some(org_id) = user.organization_id { + let has_access: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM cabinets c \ + JOIN projects p ON c.project_id = p.id \ + WHERE c.imei = ? AND p.organization_id = ?)", + ) + .bind(¶ms.imei) + .bind(org_id) + .fetch_one(&state.mysql) + .await + .unwrap_or(false); + if !has_access { + return Err(AppError::Forbidden("无权访问该设备".into())); + } + } else { + return Err(AppError::Forbidden("无权访问该设备".into())); + } + } + let client = redis::Client::open(state.redis_url.as_str()).map_err(|e| { AppError::Internal(format!("Redis客户端创建失败: {}", e)) })?; - let mut conn = client.get_async_connection().await.map_err(|e| { + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + let mut conn = client.get_multiplexed_async_connection().await.map_err(|e| { AppError::Internal(format!("Redis连接失败: {}", e)) })?; @@ -382,7 +444,7 @@ pub async fn device_realtime_status( continue; } - let ch_fields = vec!["on", "full", "fault", "hex"]; + let ch_fields = vec!["on", "full", "fault", "hex", "temp"]; let ch_values: Vec> = redis::cmd("HMGET") .arg(ch_key) .arg(&ch_fields) @@ -411,6 +473,7 @@ pub async fn device_realtime_status( "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::().ok()), }); } @@ -419,7 +482,11 @@ pub async fn device_realtime_status( } Ok(Json(json!({ - "device": device_status, - "boards": boards, + "code": 0, + "data": { + "device": device_status, + "boards": boards, + }, + "message": "ok" }))) } diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs index 0712114..b5897ea 100644 --- a/software/api-server/src/routes/cabinets.rs +++ b/software/api-server/src/routes/cabinets.rs @@ -12,11 +12,11 @@ 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)] @@ -28,7 +28,11 @@ struct CabinetBasicRow { iccid: Option, name: Option, address: Option, + latitude: Option, + longitude: Option, status: i8, + /// 充电工作模式:0=平台指令模式 1=自动充电工作模式 + work_mode: Option, } impl From for super::organizations::CabinetRow { @@ -52,6 +56,8 @@ impl From for super::organizations::CabinetRow { pow_fail_dc: None, pow_fail_ac: None, is_online: None, + org_disabled: None, + project_address: None, } } } @@ -109,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) @@ -117,7 +123,7 @@ pub async fn list_cabinets( .await?; let rows: Vec = sql_rows.into_iter().map(Into::into).collect(); - Ok(Json(json!(rows))) + Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } /// POST /api/cabinets — 批量添加柜子 @@ -129,6 +135,7 @@ pub async fn create_cabinet( Json(body): Json, ) -> Result, 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() { @@ -191,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 — 更新柜子(划拨项目等) @@ -202,6 +209,7 @@ pub async fn update_cabinet( Json(body): Json, ) -> Result, 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( @@ -224,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, + Path(id): Path, + Json(body): Json, +) -> Result, 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, + Path(id): Path, + Json(body): Json, +) -> Result, 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 — 删除柜子(级联删除仓控板和仓体) @@ -234,6 +331,7 @@ pub async fn delete_cabinet( Path(id): Path, ) -> Result, 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)?; @@ -265,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 @@ -275,6 +373,7 @@ pub async fn bind_cabinet( Path(id): Path, ) -> Result, AppError> { auth::check_permission(&user, "device:edit")?; + scope_guard::verify_cabinet_ownership(&state.mysql, &user, id).await?; let db = &state.mysql; @@ -314,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(换模组) @@ -325,6 +424,7 @@ pub async fn replace_imei( Json(body): Json, ) -> Result, 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()) { @@ -366,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 — 重新生成安全码 @@ -375,24 +475,55 @@ pub async fn regenerate_auth( State(state): State, Path(id): Path, ) -> Result, 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 = 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" }))) } // --------------------------------------------------------------------------- @@ -409,7 +540,7 @@ pub async fn get_cabinet_detail( 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) @@ -422,17 +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 = 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, + pow_fail_dc: Option, + pow_fail_ac: Option, + /// 仓体 id -> (SOC, 电池ID) + soc_map: std::collections::HashMap, Option)>, + /// 仓体 id -> (温度, batt_status, pow_status) + ch_map: std::collections::HashMap, Option, Option)>, +} + +/// 查询仓控板与仓体,构建带实时字段占位的 JSON +/// +/// 返回 `(柜控板JSON列表, 仓体id列表, 仓体id->(板号,通道号)映射)`, +/// 后两者供 Redis 批量查询通道级状态使用(板号/通道号为十进制不补零)。 +async fn load_boards_with_compartments( + db: &MySqlPool, + cabinet_id: i64, +) -> Result<(Vec, Vec, Vec<(i64, (i8, i8))>), AppError> { let boards: Vec = 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?; - // 查询仓控板+仓体,同时构建 Value 以便注入 SOC let mut cabin_boards: Vec = Vec::new(); let mut comp_ids: Vec = Vec::new(); + let mut comp_to_board: Vec<(i64, (i8, i8))> = Vec::new(); + for board in boards { let compartments: Vec = sqlx::query_as( r#"SELECT id, cabin_board_id, channel_id, status @@ -444,15 +645,22 @@ pub async fn get_cabinet_detail( for c in &compartments { comp_ids.push(c.id); + comp_to_board.push((c.id, (board.board_id, c.channel_id))); } - let comp_values: Vec = compartments.into_iter().map(|c| { - json!({ - "id": c.id, - "channel_id": c.channel_id, - "status": c.status, + // batt_status / pow_status 为 2 位 hex 字符串,Redis 缺失时保持 null + let comp_values: Vec = 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(); + .collect(); cabin_boards.push(json!({ "id": board.id, @@ -462,71 +670,135 @@ pub async fn get_cabinet_detail( })); } - // 从 Redis 批量读取实时状态(RSSI/电源/SOC) - let (rssi, pow_fail_dc, pow_fail_ac, mut soc_map) = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { - if let Ok(mut conn) = client.get_async_connection().await { - let key = format!("device:{}", cabinet.imei); - let mut pipe = redis::pipe(); - pipe.cmd("HGET").arg(&key).arg("rssi") - .cmd("HGET").arg(&key).arg("pow_fail_dc") - .cmd("HGET").arg(&key).arg("pow_fail_ac"); - for cid in &comp_ids { - pipe.cmd("GET").arg(format!("compartment:status:{}", cid)); - } - let vals: Vec> = pipe.query_async(&mut conn).await.unwrap_or_default(); - let rssi: Option = vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse().ok()); - let dc = vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1"); - let ac = vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1"); - let mut soc_map = std::collections::HashMap::new(); - for (i, cid) in comp_ids.iter().enumerate() { - if let Some(Some(data)) = vals.get(3 + i) { - if let Ok(v) = serde_json::from_str::(data) { - let soc = v.get("soc").and_then(|s| s.as_i64()); - let bid = v.get("battery_id").and_then(|s| s.as_str()).map(|s| s.to_string()); - soc_map.insert(*cid, (soc, bid)); - } - } - } - (rssi, dc, ac, soc_map) - } else { (None, None, None, std::collections::HashMap::new()) } - } else { (None, None, None, std::collections::HashMap::new()) }; + Ok((cabin_boards, comp_ids, comp_to_board)) +} - // 注入 SOC 到每个仓体 - for board in &mut cabin_boards { - if let Some(comps) = board.get_mut("compartments").and_then(|c| c.as_array_mut()) { - for comp in comps { - if let Some(id) = comp.get("id").and_then(|i| i.as_i64()) { - if let Some((soc, bid)) = soc_map.get(&id) { - if let Some(s) = soc { - comp["soc"] = json!(s); - } - if let Some(b) = bid { - comp["battery_id"] = json!(b); - } - } - } +/// 一次 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>, 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], + comp_ids: &[i64], + comp_to_board: &[(i64, (i8, i8))], +) -> CabinetRealtime { + /// 取出第 i 项的非空字符串 + fn pick(vals: &[Option], i: usize) -> Option { + 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::(&data) { + let soc = v.get("soc").and_then(|s| s.as_i64()); + let bid = v.get("battery_id").and_then(|s| s.as_str()).map(str::to_string); + rt.soc_map.insert(*cid, (soc, bid)); } } } - Ok(Json(json!({ - "code": 0, - "data": { - "id": cabinet.id, - "project_id": cabinet.project_id, - "abstract_id": cabinet.abstract_id, - "imei": cabinet.imei, - "iccid": cabinet.iccid, - "name": cabinet.name, - "status": cabinet.status, - "rssi": rssi, - "pow_fail_dc": pow_fail_dc, - "pow_fail_ac": pow_fail_ac, - "address": cabinet.address, - "cabin_boards": cabin_boards, - }, - "message": "ok" - }))) + // 通道级字段紧随 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::().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); + } + } + } + } } // --------------------------------------------------------------------------- @@ -638,3 +910,4 @@ pub async fn list_compartment_options( Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" }))) } + diff --git a/software/api-server/src/routes/charge_orders.rs b/software/api-server/src/routes/charge_orders.rs new file mode 100644 index 0000000..61fc4e2 --- /dev/null +++ b/software/api-server/src/routes/charge_orders.rs @@ -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, + pub compartment_id: Option, + pub battery_id: Option, + pub status: Option, + pub start_time: Option, + pub end_time: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Serialize)] +pub struct ChargeOrderItem { + pub id: i64, + pub cabinet_id: i64, + pub cabinet_name: Option, + pub compartment_id: i64, + pub battery_id: String, + pub start_time: Option, + pub end_time: Option, + pub start_soc: i64, + pub end_soc: Option, + pub energy: i64, + pub reason: Option, + pub reason_text: String, + pub status: i8, + pub status_text: String, + pub created_at: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct ChargeOrderRow { + id: i64, + cabinet_id: i64, + cabinet_name: Option, + compartment_id: i64, + battery_id: String, + start_time: Option, + end_time: Option, + start_soc: i64, + end_soc: Option, + energy: i64, + reason: Option, + status: i8, + created_at: Option, +} + +/// GET /api/charge-orders — 充电订单列表 +pub async fn list_charge_orders( + user: CurrentUser, + State(state): State, + Query(params): Query, +) -> Result, AppError> { + auth::check_permission(&user, "charge_record:view")?; + + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(20).min(100); + let offset = (page - 1) * page_size; + + let mut query = + "SELECT co.*, cab.name as cabinet_name FROM charge_orders co LEFT JOIN cabinets cab ON co.cabinet_id = cab.id WHERE 1=1" + .to_string(); + let mut count_query = "SELECT COUNT(*) FROM charge_orders WHERE 1=1".to_string(); + + // 按固定顺序追加 `?` 占位符,绑定顺序必须与下方 build_count_query / build_list_query 一致 + if params.cabinet_id.is_some() { + query.push_str(" AND cabinet_id = ?"); + count_query.push_str(" AND cabinet_id = ?"); + } + if params.compartment_id.is_some() { + query.push_str(" AND compartment_id = ?"); + count_query.push_str(" AND compartment_id = ?"); + } + if params.battery_id.is_some() { + query.push_str(" AND battery_id LIKE ?"); + count_query.push_str(" AND battery_id LIKE ?"); + } + if params.status.is_some() { + query.push_str(" AND status = ?"); + count_query.push_str(" AND status = ?"); + } + if params.start_time.is_some() { + query.push_str(" AND start_time >= ?"); + count_query.push_str(" AND start_time >= ?"); + } + if params.end_time.is_some() { + query.push_str(" AND end_time <= ?"); + count_query.push_str(" AND end_time <= ?"); + } + + query.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); + + let total: i64 = build_count_query(count_query, ¶ms, &state.mysql).await?; + let rows: Vec = build_list_query(query, params, page_size, offset, &state.mysql).await?; + + let items: Vec = 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 { + 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, 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 +} diff --git a/software/api-server/src/routes/charge_records.rs b/software/api-server/src/routes/charge_records.rs index cb6dc7d..a83bfb9 100644 --- a/software/api-server/src/routes/charge_records.rs +++ b/software/api-server/src/routes/charge_records.rs @@ -249,11 +249,13 @@ pub async fn export_charge_records( let user_id = user.user_id; let file_name = generate_export_filename(¶ms); - // 序列化查询参数 + // 序列化查询参数(含组织过滤信息) let query_json = serde_json::to_string(&json!({ "cabinet_id": params.cabinet_id, "start_time": params.start_time, "end_time": params.end_time, + "org_id": user.organization_id, + "role_level": user.role_level, })) .unwrap_or_default(); @@ -328,7 +330,7 @@ async fn generate_charge_records_excel( let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({})); - let (where_clause, bind_values) = build_where_clause( + let (mut where_clause, mut bind_values) = build_where_clause( params.get("cabinet_id").and_then(|v| v.as_i64()), None, None, @@ -336,6 +338,19 @@ async fn generate_charge_records_excel( params.get("end_time").and_then(|v| v.as_str()), ); + // 组织过滤(非总管理员) + let role_level = params.get("role_level").and_then(|v| v.as_i64()).unwrap_or(0); + let org_id = params.get("org_id").and_then(|v| v.as_i64()); + if role_level < 2 { + let prefix = if where_clause.is_empty() { "WHERE " } else { " AND " }; + if let Some(oid) = org_id { + where_clause.push_str(&format!("{}cr.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)", prefix)); + bind_values.push(oid.to_string()); + } else { + where_clause.push_str(&format!("{}1=0", prefix)); + } + } + let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?; // 更新进度 50% @@ -351,7 +366,7 @@ async fn generate_charge_records_excel( let headers = [ "ID", "柜子", "仓板", "通道", "开始时间", "结束时间", - "充电量(Wh)", "最高温度(0.1°C)", "状态", + "充电量(Wh)", "最高温度(K×100)", "状态", ]; for (col, header) in headers.iter().enumerate() { sheet.write_string(0, col as u16, *header)?; diff --git a/software/api-server/src/routes/energy_stats.rs b/software/api-server/src/routes/energy_stats.rs index f19ca8a..2533783 100644 --- a/software/api-server/src/routes/energy_stats.rs +++ b/software/api-server/src/routes/energy_stats.rs @@ -11,8 +11,8 @@ use serde_json::{json, Value}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; -/// 电费单价(元/Wh),energy 单位为 Wh,0.80 元/kWh = 0.0008 元/Wh -const COST_PER_WH: f64 = 0.0008; +/// 电费单价,0.80 元/kWh = 80 分 / 1000 Wh +const COST_PER_WH_CENT: i64 = 80; /// 趋势查询参数 #[derive(Debug, Deserialize)] @@ -150,7 +150,7 @@ pub async fn get_summary( "data": { "today_energy": today_energy, "month_energy": month_energy, - "estimated_cost": ((month_energy as f64) * COST_PER_WH * 100.0).round() / 100.0, + "estimated_cost": (month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0, "cabinet_count": cabinet_count, "online_count": online_count, }, @@ -181,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 @@ -236,14 +239,17 @@ pub async fn get_project_stats( "SELECT p.id, p.name, \ COUNT(DISTINCT c.id) AS cabinet_count, \ CAST(COALESCE(SUM(es_today.total_energy), 0) AS SIGNED) AS today_energy, \ - CAST(COALESCE(SUM(es_month.total_energy), 0) AS SIGNED) AS month_energy \ + CAST(COALESCE(SUM(es_month.month_energy), 0) AS SIGNED) AS month_energy \ FROM projects p \ 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", @@ -365,12 +371,14 @@ pub async fn export_energy_stats( let user_id = user.user_id; let file_name = generate_export_filename(¶ms); - // 序列化查询参数 + // 序列化查询参数(含组织过滤信息) let query_json = serde_json::to_string(&json!({ "cabinet_id": params.cabinet_id, "project_id": params.project_id, "start_date": params.start_date, "end_date": params.end_date, + "org_id": user.organization_id, + "role_level": user.role_level, })) .unwrap_or_default(); @@ -475,13 +483,27 @@ async fn generate_energy_excel( 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( + 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)); + } + } + // 查询能耗明细 let sql = format!( "SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \ diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs index ef93d67..ff99bef 100644 --- a/software/api-server/src/routes/h5.rs +++ b/software/api-server/src/routes/h5.rs @@ -11,13 +11,15 @@ //! - POST /api/door/open — 开门 //! - GET /api/cabinets/scan/:abstract_id — 扫码验证 -use axum::extract::{Path, Query, State}; +use axum::extract::{Multipart, Path, Query, State}; use axum::Json; use redis; use serde::Deserialize; use serde_json::{json, Value}; +use std::io::Write; +use uuid::Uuid; -use crate::commands::{AppState, DeviceCommand}; +use crate::commands::{AppState, DeviceCommand, ROUTE_DEV_ID_FIELD}; use crate::error::AppError; use crate::middleware::auth::{self, CurrentUser}; @@ -31,6 +33,17 @@ pub(crate) struct CompartmentActionInput { compartment_id: i64, } +/// 问题反馈请求体 +#[derive(Debug, Deserialize)] +pub(crate) struct FeedbackInput { + /// 反馈内容 + content: String, + /// 联系方式(可选) + contact: Option, + /// 图片URL列表(可选) + images: Option>, +} + /// 设备列表查询参数 #[derive(Debug, Deserialize)] pub(crate) struct CabinetListQuery { @@ -64,6 +77,10 @@ struct H5CabinetRow { status: i8, project_id: Option, imei: Option, + iccid: Option, + address: Option, + latitude: Option, + longitude: Option, channel_count: i64, idle_count: i64, charging_count: i64, @@ -96,10 +113,13 @@ struct ChargeRecordBriefRow { energy: Option, } -/// 柜子 IMEI 查询行(用于指令下发) +/// 柜子 IMEI + 仓控板 + 通道查询行(用于指令下发) #[derive(Debug, sqlx::FromRow)] struct CabinetImeiRow { + cabinet_id: i64, imei: String, + board_id: i8, + channel_id: i8, } // --------------------------------------------------------------------------- @@ -115,7 +135,7 @@ pub async fn get_dashboard( let pool = &state.mysql; // 根据角色确定组织过滤条件 - // 超级管理员可通过 org_id 参数切换组织 + // 总管理员可通过 org_id 参数切换组织 let is_super = user.role_level >= 2 && query.org_id.is_none(); let org_id = query.org_id.or(user.organization_id); @@ -151,8 +171,8 @@ pub async fn get_dashboard( // 故障告警(取最近10条故障仓体,返回项目名+柜名) let alerts = fetch_fault_alerts(pool, is_super, org_id).await?; - // 项目摘要 - let projects = fetch_project_summaries(pool, &user).await?; + // 项目摘要(带组织过滤) + let projects = fetch_project_summaries(pool, &user, org_id).await?; Ok(Json(json!({ "code": 0, @@ -414,12 +434,23 @@ async fn fetch_fault_alerts( async fn fetch_project_summaries( pool: &sqlx::MySqlPool, user: &CurrentUser, + org_id: Option, ) -> Result, AppError> { - // 获取用户可见的项目 + // 获取用户可见的项目(优先使用查询参数 org_id) + let filter_org = org_id.or(user.organization_id); let projects: Vec = if user.role_level >= 2 { - sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id") + if let Some(oid) = filter_org { + sqlx::query_as::<_, ProjectRow>( + "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", + ) + .bind(oid) .fetch_all(pool) .await? + } else { + sqlx::query_as::<_, ProjectRow>("SELECT id, name FROM projects ORDER BY id") + .fetch_all(pool) + .await? + } } else if let Some(org_id) = user.organization_id { sqlx::query_as::<_, ProjectRow>( "SELECT id, name FROM projects WHERE organization_id = ? ORDER BY id", @@ -502,7 +533,7 @@ pub async fn get_cabinets( // 按项目过滤(同时验证组织权限) if user.role_level >= 2 { sqlx::query_as::<_, H5CabinetRow>( - r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -515,7 +546,7 @@ pub async fn get_cabinets( .await? } else if let Some(org_id) = user.organization_id { sqlx::query_as::<_, H5CabinetRow>( - r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -536,7 +567,7 @@ pub async fn get_cabinets( // 总管理员:按组织过滤或看全部 if let Some(oid) = query.org_id { sqlx::query_as::<_, H5CabinetRow>( - r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -551,7 +582,7 @@ pub async fn get_cabinets( .await? } else { sqlx::query_as::<_, H5CabinetRow>( - r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -565,7 +596,7 @@ pub async fn get_cabinets( } else if let Some(org_id) = user.organization_id { // 企业管理员只看本组织 sqlx::query_as::<_, H5CabinetRow>( - r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.project_id, + r#"SELECT c.id, c.abstract_id, c.name, c.status, c.imei, c.iccid, c.project_id, c.address, CAST(c.latitude AS CHAR) AS latitude, CAST(c.longitude AS CHAR) AS longitude, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id) as channel_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 0) as idle_count, (SELECT COUNT(*) FROM compartments comp JOIN cabin_boards cb ON comp.cabin_board_id = cb.id WHERE cb.cabinet_id = c.id AND comp.status = 1) as charging_count, @@ -582,23 +613,62 @@ pub async fn get_cabinets( vec![] }; - // 从Redis批量查RSSI + // 从Redis批量查RSSI + pow_fail let mut rssi_map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut dc_map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut ac_map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut temp_map: std::collections::HashMap> = std::collections::HashMap::new(); if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { - if let Ok(mut conn) = client.get_async_connection().await { + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + if let Ok(mut conn) = client.get_multiplexed_async_connection().await { let mut pipe = redis::pipe(); for r in &rows { if let Some(ref imei) = r.imei { - pipe.cmd("HGET").arg(&format!("device:{imei}")).arg("rssi"); + let key = format!("device:{imei}"); + pipe.cmd("HMGET").arg(&key).arg(&["rssi", "pow_fail_dc", "pow_fail_ac", "temp"]); } } if !rows.is_empty() { - let results: Vec> = pipe.query_async(&mut conn).await.unwrap_or_default(); - for (i, r) in rows.iter().enumerate() { - if r.imei.is_some() { - let val = results.get(i).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok()); - rssi_map.insert(r.imei.clone().unwrap_or_default(), val); + let results: Vec>> = match pipe.query_async(&mut conn).await { + Ok(r) => r, + Err(e) => { + tracing::error!("[h5] Redis pipeline查询失败: {}", e); + Vec::new() } + }; + for (i, r) in rows.iter().enumerate() { + if let Some(ref imei) = r.imei { + if let Some(vals) = results.get(i) { + rssi_map.insert(imei.clone(), vals.get(0).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); + dc_map.insert(imei.clone(), vals.get(1).and_then(|v| v.as_ref()).map(|v| v == "1")); + ac_map.insert(imei.clone(), vals.get(2).and_then(|v| v.as_ref()).map(|v| v == "1")); + temp_map.insert(imei.clone(), vals.get(3).and_then(|v| v.as_ref()).and_then(|v| v.parse::().ok())); + } + } + } + } + } + } + + // 查询柜子所属组织是否禁用(用于前端徽标) + let mut org_disabled_map: std::collections::HashMap = std::collections::HashMap::new(); + { + let project_ids: Vec = rows.iter().filter_map(|r| r.project_id).collect(); + if !project_ids.is_empty() { + let placeholders: Vec = project_ids.iter().map(|_| "?".to_string()).collect(); + let sql = format!( + r#"SELECT p.id, IFNULL(o.disabled, 0) FROM projects p + LEFT JOIN organizations o ON p.organization_id = o.id + WHERE p.id IN ({})"#, + placeholders.join(",") + ); + let mut q = sqlx::query_as::<_, (i64, i8)>(&sql); + for pid in &project_ids { + q = q.bind(pid); + } + if let Ok(rows) = q.fetch_all(pool).await { + for (pid, disabled) in rows { + org_disabled_map.insert(pid, disabled == 1); } } } @@ -607,19 +677,33 @@ pub async fn get_cabinets( let list: Vec = rows .into_iter() .map(|r| { - let rssi = r.imei.as_ref().and_then(|imei| rssi_map.get(imei).copied()).flatten(); + let imei = r.imei.clone(); + let rssi = imei.as_ref().and_then(|i| rssi_map.get(i).copied()).flatten(); + let pow_fail_dc = imei.as_ref().and_then(|i| dc_map.get(i).copied()).flatten(); + let pow_fail_ac = imei.as_ref().and_then(|i| ac_map.get(i).copied()).flatten(); + let temp = imei.as_ref().and_then(|i| temp_map.get(i).copied()).flatten(); + let org_disabled = r.project_id.and_then(|pid| org_disabled_map.get(&pid)).copied().unwrap_or(false); json!({ "id": r.id, "abstract_id": r.abstract_id, "name": r.name, + "imei": r.imei, + "iccid": r.iccid, "status": r.status, "project_id": r.project_id, + "address": r.address, + "latitude": r.latitude, + "longitude": r.longitude, "channel_count": r.channel_count, "idle_count": r.idle_count, "charging_count": r.charging_count, "full_count": r.full_count, "fault_count": r.fault_count, "rssi": rssi, + "pow_fail_dc": pow_fail_dc, + "pow_fail_ac": pow_fail_ac, + "temp": temp, + "org_disabled": org_disabled, }) }) .collect(); @@ -640,14 +724,14 @@ pub async fn get_cabinet_by_abstract_id( ) -> Result, AppError> { let pool = &state.mysql; - let cab: Option<(i64, String, String, Option, Option, i8)> = sqlx::query_as( - "SELECT id, abstract_id, imei, iccid, name, status FROM cabinets WHERE abstract_id = ?", + let cab: Option<(i64, String, String, Option, i8)> = sqlx::query_as( + "SELECT id, abstract_id, imei, name, status FROM cabinets WHERE abstract_id = ?", ) .bind(&abstract_id) .fetch_optional(pool) .await?; - let (cab_id, abs_id, imei, iccid, name, status) = + let (cab_id, abs_id, _imei, name, status) = cab.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; // 查询仓控板+仓体 @@ -685,8 +769,6 @@ pub async fn get_cabinet_by_abstract_id( "data": { "id": cab_id, "abstract_id": abs_id, - "imei": imei, - "iccid": iccid, "name": name, "status": status, "cabin_boards": cabin_boards, @@ -735,7 +817,8 @@ pub async fn get_compartment_detail( // 从 Redis 获取实时数据 let realtime: Value = if let Ok(client) = redis::Client::open(state.redis_url.as_str()) { - if let Ok(mut conn) = client.get_async_connection().await { + // 使用多路复用连接(MultiplexedConnection),替代已废弃的 aio::Connection + if let Ok(mut conn) = client.get_multiplexed_async_connection().await { let key = format!("compartment:status:{}", comp_id); let val: Option = redis::cmd("GET").arg(&key).query_async(&mut conn).await.unwrap_or(None); val.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})) @@ -775,10 +858,8 @@ pub async fn get_compartment_detail( "cabin_board_id": cabin_board_id, "voltage": realtime.get("voltage").and_then(|v| v.as_i64()).unwrap_or(0), "current": realtime.get("current").and_then(|v| v.as_i64()).unwrap_or(0), - "power": realtime.get("power").and_then(|v| v.as_i64()).unwrap_or(0), "energy": realtime.get("energy").and_then(|v| v.as_i64()).unwrap_or(0), "soc": realtime.get("soc").and_then(|v| v.as_i64()).unwrap_or(0), - "soh": realtime.get("soh").and_then(|v| v.as_i64()).unwrap_or(0), "cell_voltages": realtime.get("cell_voltages").cloned().unwrap_or(json!([])), "cell_temps": realtime.get("cell_temps").cloned().unwrap_or(json!([])), "bms_alerts": realtime.get("bms_alerts").cloned().unwrap_or(json!([])), @@ -835,13 +916,82 @@ pub async fn scan_cabinet( // --------------------------------------------------------------------------- /// POST /api/h5/charge/start — 开始充电 +/// +/// 协议 §5.7:**`on` 是建立订单的唯一入口**。平台指令模式(`work_mode="0"`)下由**平台**下发 `on`, +/// 因此平台必须在**下发前**建单 —— 设备随后回的 `on` 只是回执(带 `suc`),回执不建单。 +/// 建单失败只记日志、**绝不影响指令下发**(充电优先于订单统计)。 pub async fn start_charge( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge:start")?; - send_compartment_command(&state, body.compartment_id, "on").await + + // 下发前建单:一次查出柜子/仓控板/通道 + 充电工作模式 + // (`cabinets.work_mode` 是 TINYINT:0=平台指令模式 / 1=自动充电模式 / NULL 按 0 处理) + let order_row: Result)>, sqlx::Error> = sqlx::query_as( + r#"SELECT cab.id, cab.imei, cb.board_id, comp.channel_id, cab.work_mode + FROM cabinets cab + JOIN cabin_boards cb ON cb.cabinet_id = cab.id + JOIN compartments comp ON comp.cabin_board_id = cb.id + WHERE comp.id = ?"#, + ) + .bind(body.compartment_id) + .fetch_optional(&state.mysql) + .await; + + match order_row { + // 只有**平台指令模式**(`work_mode="0"`/NULL)才由平台下发 `on` 并在此建单; + // **自动充电模式**(`"1"`)下订单由设备上报的 `on`(不带 `suc`)建立,平台不重复建单 + Ok(Some((cabinet_id, imei, board_id, channel_id, mode))) if mode.unwrap_or(0) == 0 => { + // 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`) + match crate::workers::report_worker::load_channel_battery( + &state.redis, &imei, board_id, channel_id, + ) + .await + { + Some(battery) => { + if let Err(e) = crate::workers::report_worker::ensure_charge_order( + &state.mysql, + &state.redis, + &imei, + cabinet_id, + board_id, + channel_id, + &battery.battery_id, + Some(battery.soc), + ) + .await + { + tracing::error!( + "[h5] 下发 on 前建单失败 compartment_id={} err={}", + body.compartment_id, + e + ); + } + } + None => tracing::warn!( + "[h5] 该通道无 bat_in 登记,跳过建单 compartment_id={}", + body.compartment_id + ), + } + } + Ok(Some(_)) => tracing::info!( + "[h5] 自动充电模式(work_mode=1),建单由设备上报的 on 负责 compartment_id={}", + body.compartment_id + ), + Ok(None) => tracing::warn!( + "[h5] 下发 on 前跳过建单:仓体不存在 compartment_id={}", + body.compartment_id + ), + Err(e) => tracing::error!( + "[h5] 下发 on 前跳过建单:查询仓体出错 compartment_id={} err={}", + body.compartment_id, + e + ), + } + + send_compartment_command(&state, &user, body.compartment_id, "on").await } /// POST /api/h5/charge/stop — 停止充电 @@ -851,34 +1001,53 @@ pub async fn stop_charge( Json(body): Json, ) -> Result, AppError> { auth::check_permission(&user, "charge:stop")?; - send_compartment_command(&state, body.compartment_id, "off").await + send_compartment_command(&state, &user, body.compartment_id, "off").await } -/// POST /api/h5/door/open — 开门 +/// POST /api/h5/door/open — 开门(**柜级动作**) +/// +/// 入参是仓体 ID(小程序扫码场景只知道扫到哪个仓), +/// 但**开门是整柜解锁** —— 解析出所属柜子后按**柜级**下发(协议 `id` 省略)。 pub async fn open_door( user: CurrentUser, State(state): State, Json(body): Json, ) -> Result, AppError> { - auth::check_permission(&user, "device:door:unlock")?; - send_compartment_command(&state, body.compartment_id, "open").await + auth::check_permission(&user, "charge:open_door")?; + + let cabinet_id: Option = sqlx::query_scalar( + r#"SELECT cb.cabinet_id FROM compartments comp + JOIN cabin_boards cb ON cb.id = comp.cabin_board_id + WHERE comp.id = ?"#, + ) + .bind(body.compartment_id) + .fetch_optional(&state.mysql) + .await?; + let cabinet_id = cabinet_id.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?; + + send_cabinet_command(&state, &user, cabinet_id, None, "open").await } // --------------------------------------------------------------------------- // 辅助函数 // --------------------------------------------------------------------------- -/// 根据仓体ID查找所属柜子的IMEI,并通过 Redis 下发指令 +/// 根据仓体ID解析所属柜子/仓控板/通道,下发**仓体级**指令(`on` / `off`)。 +/// +/// 协议 `id` = `仓控板-通道`(两段,仓控板 2 位补零)。 +/// 柜级指令(`open` / `pow_on` / `pow_off` / `reset`)请用 [`send_cabinet_command`]。 async fn send_compartment_command( state: &AppState, + user: &CurrentUser, compartment_id: i64, action: &str, ) -> Result, AppError> { let pool = &state.mysql; - // 通过 compartment → cabin_board → cabinet 链路查找 IMEI 和 sub_device_id + // 一次查询拿到 cabinet_id + IMEI + board_id + channel_id let row: Option = sqlx::query_as( - r#"SELECT cab.imei FROM cabinets cab + r#"SELECT cab.id AS cabinet_id, cab.imei, cb.board_id, comp.channel_id + FROM cabinets cab JOIN cabin_boards cb ON cb.cabinet_id = cab.id JOIN compartments comp ON comp.cabin_board_id = cb.id WHERE comp.id = ?"#, @@ -888,35 +1057,61 @@ async fn send_compartment_command( .await?; let cab_row = row.ok_or_else(|| AppError::NotFound("仓体对应的设备不存在".into()))?; - let imei = &cab_row.imei; - // 查询 sub_device_id(仓控板 ID) - let sub_row: Option<(i8,)> = sqlx::query_as( - r#"SELECT cb.board_id FROM cabin_boards cb - JOIN compartments comp ON comp.cabin_board_id = cb.id - WHERE comp.id = ?"#, + // 检查设备是否被禁用 + let disabled: bool = sqlx::query_scalar( + "SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2", ) - .bind(compartment_id) - .fetch_optional(pool) - .await?; + .bind(cab_row.cabinet_id) + .fetch_one(pool) + .await + .map_err(AppError::Database)?; + if disabled { + return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into())); + } - let sub_device_id = sub_row.map(|(bid,)| bid as u8); + // 检查柜子所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效) + let org_disabled: bool = sqlx::query_scalar( + r#"SELECT COUNT(*) > 0 FROM cabinets c + INNER JOIN projects p ON c.project_id = p.id + INNER JOIN organizations o ON p.organization_id = o.id + WHERE c.id = ? AND o.disabled = 1"#, + ) + .bind(cab_row.cabinet_id) + .fetch_one(pool) + .await + .map_err(AppError::Database)?; + if org_disabled { + return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into())); + } + + // 校验操作权限(组织隔离) + verify_cabinet_access(user, cab_row.cabinet_id, pool).await?; + + let imei = &cab_row.imei; // 检查是否有设备节点在线 if !state.nodes.is_device_online(imei).await { return Err(AppError::BadRequest("设备不在线".into())); } - // 构建指令并通过 Redis 下发 + // 构造协议 id: + // 仓体级指令 (on/off):`仓控板-通道`(仓控板 2 位补零、通道不补零) + // 柜级指令 (open/pow_on/pow_off/reset):【不带 id】—— 设备身份与目标都由连接确定 + let id = format!("{:02}-{}", cab_row.board_id, cab_row.channel_id); + let cmd = DeviceCommand { act: action.to_string(), msg_id: 0, - dev_id: Some(imei.clone()), - sub_device_id, + id: Some(id.clone()), params: Some(json!({})), }; - let json_str = cmd.to_json(); + // 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见) + let mut json_val = + serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?; + json_val[ROUTE_DEV_ID_FIELD] = json!(imei); + let json_str = json_val.to_string(); let mut conn = state.redis.clone(); redis::cmd("LPUSH") .arg("device:commands") @@ -926,8 +1121,8 @@ async fn send_compartment_command( .map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?; tracing::info!( - "[h5] 指令已推入队列 imei={} action={} compartment_id={}", - imei, action, compartment_id + "[h5] 指令已推入队列 id={} action={} compartment_id={}", + id, action, compartment_id ); Ok(Json(json!({ @@ -937,6 +1132,405 @@ async fn send_compartment_command( }))) } +// --------------------------------------------------------------------------- +// 柜级 / 单仓控板级指令(pow_on / pow_off / reset) +// --------------------------------------------------------------------------- + +/// 柜级 / 单仓控板级指令下发。 +/// +/// - `board_id = None` → **柜级**:协议**不带 `id`**(省略该字段) +/// - `board_id = Some(n)` → **单仓控板**:协议 `id = "{仓控板:02}"`(如 `"01"`) +/// +/// 适用于 `open` / `pow_on` / `pow_off` / `reset`;`on` / `off` 走 [`send_compartment_command`]。 +async fn send_cabinet_command( + state: &AppState, + user: &CurrentUser, + cabinet_id: i64, + board_id: Option, + action: &str, +) -> Result, AppError> { + let pool = &state.mysql; + + let imei: Option = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") + .bind(cabinet_id) + .fetch_optional(pool) + .await?; + let imei = imei.ok_or_else(|| AppError::NotFound("设备不存在".into()))?; + + // 设备是否被禁用 + let disabled: bool = + sqlx::query_scalar("SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2") + .bind(cabinet_id) + .fetch_one(pool) + .await + .map_err(AppError::Database)?; + if disabled { + return Err(AppError::BadRequest("设备已被禁用,无法执行操作".into())); + } + + // 所属组织是否被禁用(甲方禁用客户组织 → 其下柜子全部失效) + let org_disabled: bool = sqlx::query_scalar( + r#"SELECT COUNT(*) > 0 FROM cabinets c + INNER JOIN projects p ON c.project_id = p.id + INNER JOIN organizations o ON p.organization_id = o.id + WHERE c.id = ? AND o.disabled = 1"#, + ) + .bind(cabinet_id) + .fetch_one(pool) + .await + .map_err(AppError::Database)?; + if org_disabled { + return Err(AppError::BadRequest("设备所属组织已被禁用,无法执行操作".into())); + } + + // 组织数据隔离校验 + verify_cabinet_access(user, cabinet_id, pool).await?; + + // 设备必须在线:平台对不在线的设备一律不投递 + if !state.nodes.is_device_online(&imei).await { + return Err(AppError::BadRequest("设备不在线".into())); + } + + // 构造协议 id: + // - `board_id = None` → **柜级**:**不带 id**(省略该字段) + // - `board_id = Some(n)` → **单仓控板**:`仓控板`(2 位补零,如 "01") + let id = board_id.map(|b| format!("{:02}", b)); + + let cmd = DeviceCommand { + act: action.to_string(), + msg_id: 0, + id: id.clone(), + params: Some(json!({})), + }; + + // 注入内部路由字段(目标设备 IMEI;device-server 下发前摘除,设备侧不可见) + let mut json_val = + serde_json::to_value(&cmd).map_err(|e| AppError::Internal(format!("指令序列化失败: {}", e)))?; + json_val[ROUTE_DEV_ID_FIELD] = json!(imei); + let json_str = json_val.to_string(); + let mut conn = state.redis.clone(); + redis::cmd("LPUSH") + .arg("device:commands") + .arg(&json_str) + .query_async::<()>(&mut conn) + .await + .map_err(|e| AppError::Internal(format!("Redis 推入失败: {}", e)))?; + + tracing::info!( + "[设备指令] 已推入队列 id={:?} action={} cabinet_id={} board_id={:?}", + id, + action, + cabinet_id, + board_id + ); + + Ok(Json(json!({ + "code": 0, + "data": { "success": true }, + "message": "指令已下发" + }))) +} + +/// POST /api/cabinets/:id/open — **解锁柜门**(柜级) +/// +/// 柜门是**整柜共用、只有一扇**,所以这是柜级动作(协议 `id` 省略)。 +/// WEB 管理端设备详情页用这个;小程序扫码场景用 `POST /api/h5/door/open`(入参为仓体 ID)。 +pub async fn cabinet_open( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "charge:open_door")?; + send_cabinet_command(&state, &user, id, None, "open").await +} + +/// POST /api/cabinets/:id/pow-on — 接触器**合闸**(柜级) +pub async fn cabinet_pow_on( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:operate")?; + send_cabinet_command(&state, &user, id, None, "pow_on").await +} + +/// POST /api/cabinets/:id/pow-off — 接触器**分闸**(柜级) +/// +/// ⚠️ 分闸后**仓控板随之下电**:该柜仓体/电池数据不可用,设备会上报 `pow_fail_dc`; +/// 恢复供电须再下发 `pow-on`。 +pub async fn cabinet_pow_off( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:operate")?; + send_cabinet_command(&state, &user, id, None, "pow_off").await +} + +/// POST /api/cabinets/:id/reset — **重启整柜**(柜级,协议 `id = IMEI`) +pub async fn cabinet_reset( + user: CurrentUser, + State(state): State, + Path(id): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:operate")?; + send_cabinet_command(&state, &user, id, None, "reset").await +} + +/// POST /api/cabin-boards/:id/reset — **重启单块仓控板** +/// (协议 `id = IMEI-仓控板号`,两段) +pub async fn board_reset( + user: CurrentUser, + State(state): State, + Path(board_pk): Path, +) -> Result, AppError> { + auth::check_permission(&user, "device:operate")?; + + let row: Option<(i64, i8)> = + sqlx::query_as("SELECT cabinet_id, board_id FROM cabin_boards WHERE id = ?") + .bind(board_pk) + .fetch_optional(&state.mysql) + .await?; + let (cabinet_id, board_id) = + row.ok_or_else(|| AppError::NotFound("仓控板不存在".into()))?; + + send_cabinet_command(&state, &user, cabinet_id, Some(board_id), "reset").await +} + +/// POST /api/feedback — 用户问题反馈提交(任何已登录用户可提交) +pub async fn submit_feedback( + user: CurrentUser, + State(state): State, + Json(body): Json, +) -> Result, AppError> { + let pool = &state.mysql; + + let content = body.content.trim(); + if content.is_empty() { + return Err(AppError::BadRequest("反馈内容不能为空".into())); + } + if content.chars().count() > 2000 { + return Err(AppError::BadRequest("反馈内容过长(最多2000字)".into())); + } + let contact = body.contact.as_ref().map(|c| c.trim().to_string()).filter(|c| !c.is_empty()); + if let Some(c) = &contact { + if c.chars().count() > 200 { + return Err(AppError::BadRequest("联系方式过长".into())); + } + } + + // 图片校验:最多 6 张 + let images = body.images.clone().unwrap_or_default(); + let images: Vec = images + .iter() + .map(|u| u.trim().to_string()) + .filter(|u| !u.is_empty()) + .collect(); + if images.len() > 6 { + return Err(AppError::BadRequest("最多上传6张图片".into())); + } + let images_json = if images.is_empty() { + String::from("[]") + } else { + serde_json::to_string(&images).unwrap_or_else(|_| "[]".to_string()) + }; + + sqlx::query( + "INSERT INTO user_feedback (user_id, content, contact, images) VALUES (?, ?, ?, ?)" + ) + .bind(user.user_id) + .bind(content) + .bind(contact) + .bind(&images_json) + .execute(pool) + .await + .map_err(AppError::Database)?; + + tracing::info!( + "[h5] 问题反馈已提交 user_id={} content_len={}", + user.user_id, content.chars().count() + ); + + Ok(Json(json!({ + "code": 0, + "data": { "success": true }, + "message": "反馈已提交,感谢您的反馈" + }))) +} + +/// GET /api/feedback — 查询反馈列表(分页,管理员使用) +#[derive(Debug, Deserialize)] +pub(crate) struct FeedbackListQuery { + page: Option, + page_size: Option, +} + +#[derive(Debug, sqlx::FromRow)] +struct FeedbackRow { + id: i64, + user_id: i64, + content: String, + contact: Option, + images: Option, + status: i8, + created_at: chrono::NaiveDateTime, +} + +/// GET /api/h5/feedback — 反馈列表(管理端) +/// +/// `_user` 仅用于强制登录态(CurrentUser 提取器),当前实现按全量分页返回。 +pub async fn list_feedback( + _user: CurrentUser, + State(state): State, + Query(query): Query, +) -> Result, AppError> { + let pool = &state.mysql; + let page = query.page.unwrap_or(1).max(1); + let page_size = query.page_size.unwrap_or(20).clamp(1, 100); + let offset = (page - 1) * page_size; + + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM user_feedback") + .fetch_one(pool) + .await?; + + let rows: Vec = sqlx::query_as::<_, FeedbackRow>( + r#"SELECT id, user_id, content, contact, images, status, created_at + FROM user_feedback + ORDER BY created_at DESC + LIMIT ? OFFSET ?"#, + ) + .bind(page_size) + .bind(offset) + .fetch_all(pool) + .await?; + + let list: Vec = rows.into_iter().map(|r| { + let images: Vec = r.images + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or_default(); + json!({ + "id": r.id, + "user_id": r.user_id, + "content": r.content, + "contact": r.contact, + "images": images, + "status": r.status, + "status_text": if r.status == 0 { "待处理" } else { "已处理" }, + "created_at": r.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), + }) + }).collect(); + + Ok(Json(json!({ + "code": 0, + "data": { "list": list, "total": total, "page": page, "page_size": page_size }, + "message": "ok" + }))) +} + +/// PUT /api/feedback/{id} — 更新反馈状态(0待处理 → 1已处理) +#[derive(Debug, Deserialize)] +pub(crate) struct FeedbackStatusInput { + status: i8, +} + +const FEEDBACK_IMG_DIR: &str = "uploads/feedback"; + +pub async fn upload_feedback_image( + user: CurrentUser, + mut multipart: Multipart, +) -> Result, AppError> { + let mut file_data: Option> = None; + let mut ext = "".to_string(); + + while let Some(field) = multipart.next_field().await + .map_err(|e| AppError::Internal(format!("解析上传文件失败: {}", e)))? { + let filename = field.file_name().unwrap_or("").to_string(); + let data = field.bytes().await + .map_err(|e| AppError::Internal(format!("读取文件失败: {}", e)))?; + if let Some(pos) = filename.rfind('.') { + ext = filename[pos..].to_string(); + } + file_data = Some(data.to_vec()); + } + + let data = file_data.ok_or_else(|| AppError::BadRequest("上传内容为空".into()))?; + if data.is_empty() { + return Err(AppError::BadRequest("上传内容为空".into())); + } + + if data.len() > 5 * 1024 * 1024 { + return Err(AppError::BadRequest("图片文件不能超过 5MB".into())); + } + + if ext.is_empty() { + ext = ".jpg".to_string(); + } + let allowed = [".jpg", ".jpeg", ".png", ".gif", ".webp"]; + if !allowed.contains(&ext.as_str()) { + return Err(AppError::BadRequest("仅支持 jpg/png/gif/webp 格式".into())); + } + + let dir = std::path::Path::new(FEEDBACK_IMG_DIR); + std::fs::create_dir_all(dir) + .map_err(|e| AppError::Internal(format!("创建目录失败: {}", e)))?; + + let filename = format!("{}{}", Uuid::new_v4().simple(), ext); + let filepath = dir.join(&filename); + let mut f = std::fs::File::create(&filepath) + .map_err(|e| AppError::Internal(format!("创建文件失败: {}", e)))?; + f.write_all(&data) + .map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?; + + let url = format!("/uploads/feedback/{}", filename); + tracing::info!( + user_id = user.user_id, path = %url, + "反馈图片上传成功" + ); + + Ok(Json(json!({ + "code": 0, + "data": { "url": url }, + "message": "ok" + }))) +} + +pub async fn update_feedback_status( + user: CurrentUser, + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, AppError> { + let pool = &state.mysql; + + if body.status != 0 && body.status != 1 { + return Err(AppError::BadRequest("状态值不合法".into())); + } + + let affected = sqlx::query("UPDATE user_feedback SET status = ? WHERE id = ?") + .bind(body.status) + .bind(id) + .execute(pool) + .await + .map_err(AppError::Database)? + .rows_affected(); + + if affected == 0 { + return Err(AppError::NotFound("反馈记录不存在".into())); + } + + tracing::info!( + "[h5] 反馈状态已更新 operator={} feedback_id={} status={}", + user.user_id, id, body.status + ); + + Ok(Json(json!({ + "code": 0, + "data": { "success": true }, + "message": "ok" + }))) +} + /// 验证用户是否有权访问指定柜子(组织数据隔离) /// /// 总管理员(role_level >= 2)可访问所有柜子; diff --git a/software/api-server/src/routes/maps.rs b/software/api-server/src/routes/maps.rs new file mode 100644 index 0000000..b72742c --- /dev/null +++ b/software/api-server/src/routes/maps.rs @@ -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::>() + .join("&"); + let to_sign = format!("{}?{}{}", path, qs, TMAP_SECRET); + let digest = md5::compute(to_sign.as_bytes()); + format!("{:x}", digest) +} + +/// 构建完整请求 URL(含签名) +fn build_url(path: &str, params: BTreeMap<&str, String>) -> String { + let sig = sign(path, ¶ms); + let encoded_qs = params + .iter() + .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) + .collect::>() + .join("&"); + format!("{}{}?{}&sig={}", TMAP_BASE, path, encoded_qs, sig) +} + +// --------------------------------------------------------------------------- +// 输入提示(地点搜索建议) +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub(crate) struct SuggestionQuery { + keyword: String, + region: Option, +} + +/// GET /api/maps/suggestion — 输入提示 +pub async fn suggestion( + Query(query): Query, +) -> Result, 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, +) -> Result, 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, + page_size: Option, +} + +/// GET /api/maps/place-search — 地点搜索 +pub async fn place_search( + Query(query): Query, +) -> Result, 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)) +} \ No newline at end of file diff --git a/software/api-server/src/routes/mod.rs b/software/api-server/src/routes/mod.rs index f302e37..b14d264 100644 --- a/software/api-server/src/routes/mod.rs +++ b/software/api-server/src/routes/mod.rs @@ -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,7 +29,10 @@ 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 { @@ -32,6 +40,8 @@ pub fn build(state: AppState) -> Router { // 认证相关 .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)) // 设备指令下发 .route("/api/devices/{dev_id}/command", post(crate::commands::send_command)) .route("/api/devices/stats", get(crate::commands::device_stats)) @@ -53,6 +63,7 @@ pub fn build(state: AppState) -> Router { // 组织管理 .route("/api/organizations", get(organizations::list_organizations).post(organizations::create_organization)) .route("/api/organizations/{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)) @@ -60,7 +71,15 @@ pub fn build(state: AppState) -> Router { .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)) // 组织树 @@ -70,6 +89,10 @@ pub fn build(state: AppState) -> Router { // 充电记录 .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)) // 下载中心 @@ -102,14 +125,33 @@ pub fn build(state: AppState) -> Router { .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)) // 认证中间件层 .layer(middleware::from_fn_with_state(state.clone(), auth_middleware)); Router::::new() .route("/api/health", get(health::health_check)) + .route("/api/auth/challenge", get(auth::get_challenge)) .route("/api/auth/login", post(auth::login)) + // 微信小程序手机号快速登录(免认证) + .route("/api/auth/wx-login", post(auth::wx_login)) // 扫码入口(免认证) .route("/api/cabinets/abstract/{abstract_id}", get(h5::get_cabinet_by_abstract_id)) + // 微信回调(免认证) + .route("/api/wechat/callback", get(wechat::verify).post(wechat::handle_message)) + // 腾讯地图代理(免认证,前端需要) + .route("/api/maps/suggestion", get(maps::suggestion)) + .route("/api/maps/geocoder", post(maps::geocoder)) + .route("/api/maps/place-search", get(maps::place_search)) + // 头像上传目录(免认证,仅用于公开访问静态文件) + .nest_service("/uploads", ServeDir::new("uploads")) .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) } diff --git a/software/api-server/src/routes/operation_logs.rs b/software/api-server/src/routes/operation_logs.rs index d2641a7..4de1cbf 100644 --- a/software/api-server/src/routes/operation_logs.rs +++ b/software/api-server/src/routes/operation_logs.rs @@ -107,7 +107,7 @@ pub async fn list_operation_logs( target_type: Option, target_id: Option, detail: Option, - created_at: String, + created_at: chrono::NaiveDateTime, } let mut list_query = sqlx::query_as::<_, LogRow>(&list_sql); @@ -131,7 +131,7 @@ pub async fn list_operation_logs( "target_type": r.target_type, "target_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(); diff --git a/software/api-server/src/routes/organizations.rs b/software/api-server/src/routes/organizations.rs index 73efbad..c73a184 100644 --- a/software/api-server/src/routes/organizations.rs +++ b/software/api-server/src/routes/organizations.rs @@ -54,6 +54,13 @@ pub struct OrganizationRow { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub parent_id: Option, + #[serde(skip_serializing_if = "is_zero")] + pub disabled: Option, +} + +/// 序列化辅助:0 时跳过 disabled 字段(保持旧前端兼容) +fn is_zero(v: &Option) -> bool { + matches!(v, Some(0) | None) } /// 项目表行 @@ -63,6 +70,12 @@ pub struct ProjectRow { #[serde(skip_serializing_if = "Option::is_none")] pub organization_id: Option, pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latitude: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub longitude: Option, } /// 柜子表行 @@ -96,6 +109,10 @@ pub struct CabinetRow { pub pow_fail_dc: Option, pub pow_fail_ac: Option, pub is_online: Option, + /// 所属组织是否被禁用(甲方禁用客户组织) + pub org_disabled: Option, + /// 所属项目的地址(柜子地址为空时使用) + pub project_address: Option, } /// 树节点 @@ -111,6 +128,14 @@ pub struct TreeNode { pub imei: Option, #[serde(skip_serializing_if = "Option::is_none")] pub status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub address: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub latitude: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub longitude: Option, } /// 仓控板行 @@ -131,47 +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, -} - -/// 柜子详情(含仓控板 + 仓体) -#[derive(Debug, Serialize)] -pub struct CabinetDetail { - pub id: i64, - pub project_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub abstract_id: Option, - pub imei: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub iccid: Option, - pub name: Option, - pub status: i8, - pub rssi: Option, - pub pow_fail_dc: Option, - pub pow_fail_ac: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub address: Option, - pub cabin_boards: Vec, -} - // --------------------------------------------------------------------------- // 辅助函数 // --------------------------------------------------------------------------- -/// 生成平台抽象 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; @@ -193,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 — 创建组织 @@ -223,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 — 更新组织 @@ -253,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, + Path(id): Path, + Json(body): Json, +) -> Result, 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 — 删除组织 @@ -265,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) @@ -289,5 +351,5 @@ pub async fn delete_organization( } tracing::info!("删除组织 id={}", id); - Ok(Json(json!({}))) + Ok(Json(json!({ "code": 0, "data": null, "message": "ok" }))) } diff --git a/software/api-server/src/routes/projects.rs b/software/api-server/src/routes/projects.rs index a68718e..946d8a2 100644 --- a/software/api-server/src/routes/projects.rs +++ b/software/api-server/src/routes/projects.rs @@ -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, + #[serde(default)] + pub latitude: Option, + #[serde(default)] + pub longitude: Option, +} + /// 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, ) -> Result, 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, Path(org_id): Path, - Json(body): Json, + Json(body): Json, ) -> Result, 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, Path(id): Path, - Json(body): Json, + Json(body): Json, ) -> Result, 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, ) -> Result, 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" }))) } diff --git a/software/api-server/src/routes/scope_guard.rs b/software/api-server/src/routes/scope_guard.rs new file mode 100644 index 0000000..f3a1995 --- /dev/null +++ b/software/api-server/src/routes/scope_guard.rs @@ -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(()) +} \ No newline at end of file diff --git a/software/api-server/src/routes/users.rs b/software/api-server/src/routes/users.rs index 82bd3ec..3686384 100644 --- a/software/api-server/src/routes/users.rs +++ b/software/api-server/src/routes/users.rs @@ -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)] @@ -74,6 +73,46 @@ pub(crate) struct StatusInput { // 辅助函数: 记录操作日志 // --------------------------------------------------------------------------- +/// 企业管理员操作校验:必须操作本组织的用户且不能操作自己 +/// +/// 返回目标用户的当前组织ID(用于后续业务判断) +async fn verify_org_admin_scope( + pool: &sqlx::MySqlPool, + actor: &CurrentUser, + target_user_id: i64, +) -> Result, 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,)> = 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, @@ -95,7 +134,7 @@ async fn log_operation( .await; if let Err(e) = result { - tracing::error!("记录操作日志失败: {}", e); + tracing::error!(err = %e, "记录操作日志失败"); } } @@ -140,85 +179,53 @@ pub async fn list_users( }))); } + // 构建通用 WHERE 子句(避免 count/list 重复维护3个分支) + let (where_clause, bind_org) = match (has_keyword, org_filter) { + (true, Some(org_id)) => ( + "WHERE (phone LIKE ? OR name LIKE ?) AND organization_id = ?".to_string(), + Some(org_id), + ), + (true, None) => ( + "WHERE phone LIKE ? OR name LIKE ?".to_string(), + None, + ), + (false, Some(org_id)) => ( + "WHERE organization_id = ?".to_string(), + Some(org_id), + ), + (false, None) => (String::new(), None), + }; + // 查询总数 - let total: (i64,) = if has_keyword { - let 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); + let total: (i64,) = { + 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); } q.fetch_one(pool).await? - } else if let Some(org_id) = org_filter { - sqlx::query_as("SELECT COUNT(*) FROM users WHERE organization_id = ?") - .bind(org_id) - .fetch_one(pool) - .await? - } else { - sqlx::query_as("SELECT COUNT(*) FROM users") - .fetch_one(pool) - .await? }; // 查询用户列表 - let rows: Vec = if has_keyword { - let mut q = sqlx::query_as::<_, UserRow>( + let rows: Vec = { + 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 - WHERE u.phone LIKE ? OR u.name LIKE ? + FROM users u {} 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, u.updated_at - FROM users u - WHERE (u.phone LIKE ? OR u.name LIKE ?) AND u.organization_id = ? - ORDER BY u.id DESC - LIMIT ? OFFSET ?"#, - ) - .bind(&like_pattern) - .bind(&like_pattern) - .bind(org_id) - .bind(page_size) - .bind(offset); + 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); } - 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, u.updated_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, u.updated_at - FROM users u - ORDER BY u.id DESC - LIMIT ? OFFSET ?"#, - ) - .bind(page_size) - .bind(offset) - .fetch_all(pool) - .await? + if let Some(org_id) = bind_org { + q = q.bind(org_id); + } + q.bind(page_size).bind(offset).fetch_all(pool).await? }; // 收集组织ID并批量查询组织名称 @@ -236,12 +243,12 @@ pub async fn list_users( for id in &org_ids { q = q.bind(id); } - q.fetch_all(pool).await.unwrap_or_default() + q.fetch_all(pool).await? }; let org_map: std::collections::HashMap = org_names.into_iter().collect(); // 构建角色名称映射 - let role_names: [&str; 3] = ["普通用户", "企业管理员", "总管理员"]; + let role_names: [&str; 3] = ["操作员", "企业管理员", "总管理员"]; let list: Vec = rows .into_iter() @@ -274,6 +281,11 @@ pub async fn list_users( // --------------------------------------------------------------------------- /// 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, @@ -292,15 +304,25 @@ pub async fn create_user( let name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()); let 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 = if current_user.role_level >= 2 { + // 总管理员:创建总管理管理员时强制无组织;操作员/企业管理员必须指定组织 + if role == 2 { + None + } else { + Some(input.organization_id.ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?) + } + } else { + // 企业管理员:只能创建操作员/企业管理员,且组织强制为本组织 + if role == 2 { + return Err(AppError::Forbidden("企业管理员不能创建总管理员".into())); + } + Some(current_user.organization_id.ok_or_else(|| AppError::Forbidden("您未关联组织,无法创建用户".into()))?) + }; - let org_id = input.organization_id; // 校验组织存在 if let Some(oid) = org_id { let org_exists: (i64,) = sqlx::query_as( @@ -314,7 +336,7 @@ pub async fn create_user( } } - let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string()); + let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?; let password = auth::hash_password(&plain_password)?; // 检查手机号是否已存在 @@ -345,7 +367,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, @@ -382,28 +404,47 @@ pub async fn update_user( let new_name = input.name.filter(|s| !s.trim().is_empty()).map(|s| s.trim().to_string()); let new_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: Option = 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())); + } + Some(actor_org) + } else { + let org_id = if new_role == 2 { + None // 总管理员强制无组织 + } else { + Some(input.organization_id.or(old_org_id).ok_or_else(|| AppError::BadRequest("操作员/企业管理员必须指定所属组织".into()))?) + }; + + // 校验组织存在 + if let Some(oid) = org_id + && Some(oid) != old_org_id { + let org_exists: (i64,) = sqlx::query_as( + "SELECT COUNT(*) FROM organizations WHERE id = ?" + ) + .bind(oid) + .fetch_one(pool) + .await?; + if org_exists.0 == 0 { + return Err(AppError::BadRequest(format!("组织 {} 不存在", oid))); + } + } + org_id + }; let rows = sqlx::query( "UPDATE users SET name = ?, role = ?, organization_id = ? WHERE id = ?" @@ -430,7 +471,7 @@ pub async fn update_user( ); log_operation(pool, current_user.user_id, "edit", "user", Some(user_id), &detail).await; - tracing::info!("编辑用户 id={}", user_id); + tracing::info!(user_id, "编辑用户"); Ok(Json(json!({ "code": 0, "data": null, "message": "更新成功" }))) } @@ -449,7 +490,10 @@ pub async fn reset_password( auth::check_permission(¤t_user, "user:edit")?; let pool = &state.mysql; - let plain_password = input.password.filter(|s| !s.is_empty()).unwrap_or_else(|| DEFAULT_PASSWORD.to_string()); + // 企业管理员只能重置本组织用户密码 + verify_org_admin_scope(pool, ¤t_user, user_id).await?; + + let plain_password = input.password.filter(|s| !s.is_empty()).ok_or_else(|| AppError::BadRequest("密码不能为空".into()))?; let new_password = auth::hash_password(&plain_password)?; let rows = sqlx::query("UPDATE users SET password = ? WHERE id = ?") @@ -467,7 +511,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": "密码重置成功" }))) } @@ -487,10 +531,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, ¤t_user, user_id).await?; if ![0, 1].contains(&input.status) { return Err(AppError::BadRequest("状态值必须为0(禁用)或1(启用)".into())); @@ -513,7 +555,7 @@ pub async fn set_user_status( let detail = format!("{}用户: 用户ID={}", status_text, user_id); 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) }))) } @@ -532,10 +574,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, ¤t_user, user_id).await?; // 查询用户信息用于日志 let user_info: Option<(String,)> = sqlx::query_as( @@ -560,7 +600,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": "删除成功" }))) } diff --git a/software/api-server/src/routes/wechat.rs b/software/api-server/src/routes/wechat.rs new file mode 100644 index 0000000..0f28fbf --- /dev/null +++ b/software/api-server/src/routes/wechat.rs @@ -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, +) -> 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()) +} \ No newline at end of file diff --git a/software/api-server/src/workers/energy.rs b/software/api-server/src/workers/energy.rs new file mode 100644 index 0000000..ca2b8ad --- /dev/null +++ b/software/api-server/src/workers/energy.rs @@ -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::(&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 = 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 秒时必须显著小于按真实空洞计算,避免补报虚增电量" + ); + } +} diff --git a/software/api-server/src/workers/log_retention.rs b/software/api-server/src/workers/log_retention.rs new file mode 100644 index 0000000..c916d2e --- /dev/null +++ b/software/api-server/src/workers/log_retention.rs @@ -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 { + 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, "清理间隔不应过于频繁"); + } +} diff --git a/software/api-server/src/workers/mod.rs b/software/api-server/src/workers/mod.rs index 460609d..6fa2090 100644 --- a/software/api-server/src/workers/mod.rs +++ b/software/api-server/src/workers/mod.rs @@ -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; diff --git a/software/api-server/src/workers/order_timeout.rs b/software/api-server/src/workers/order_timeout.rs new file mode 100644 index 0000000..d5b372b --- /dev/null +++ b/software/api-server/src/workers/order_timeout.rs @@ -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)> = 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(()) +} diff --git a/software/api-server/src/workers/reply_worker.rs b/software/api-server/src/workers/reply_worker.rs index 02214f8..be76395 100644 --- a/software/api-server/src/workers/reply_worker.rs +++ b/software/api-server/src/workers/reply_worker.rs @@ -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, 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::(&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; } } diff --git a/software/api-server/src/workers/report_worker.rs b/software/api-server/src/workers/report_worker.rs index c0afe0a..8304c6a 100644 --- a/software/api-server/src/workers/report_worker.rs +++ b/software/api-server/src/workers/report_worker.rs @@ -1,8 +1,29 @@ //! 设备上报处理协程 //! -//! 从 Redis LIST `device:reports` 读取设备上报消息, -//! 执行业务逻辑:签名验证、状态持久化、充电事件处理、告警判断等。 +//! 从 Redis LIST `device:reports` 读取设备上报消息(device-server 已注入 `dev_id`), +//! 执行业务逻辑:签名验证、状态持久化、充电事件处理、指令回执配对等。 //! 需要回复设备的消息通过 `device:commands` 队列发回。 +//! +//! 协议依据:`docs/柜控板固件对接文档.md`(v1.1) +//! - 上行报文**除 `auth_str` / `login` 外一律不带 `dev_id`**,设备身份由 device-server 注入; +//! - 报文性质按**有无 `suc`** 判定:带 = 回执(平台**不再回包**),不带 = 设备请求(平台要回包); +//! - 平台应答一律用 [`ServerResponse::success_for`] / [`ServerResponse::error_for`] 构造 +//! (自动取请求的 `act` + `msg_id`),`err` 只用协议 §3.3 的英文小写短语。 +//! +//! 本模块负责的 Redis 写入契约(其他模块按此读取): +//! - `device:{imei}`(Hash,TTL 300s):`online` / `rssi` / `cabinet_status1` / +//! `cabinet_status2` / `temp` / `meter` / `pow_fail_dc` / `pow_fail_ac` +//! - `device:{imei}:board:{板号}:ch:{通道号}`(Hash):`batt_status`(2 位 hex)/ `pow_status`(2 位 hex) +//! / `soc`(‰)/ `voltage`(mV)/ `current`(mA)/ `temp`(K×100);板号与通道号**十进制不补零** +//! - `device:{imei}:board:{板号}:ch:{通道号}:bat`(Hash,内部键,TTL 48h): +//! 该通道最近一次 `bat_in` 的电池信息(`on` 建单时取电池 id 与起始 SOC) +//! +//! 本模块消费 / 删除的 key(由 device-server 写入,见协议 §3.5 / §3.6): +//! - `pending:{imei}`(Hash):`field = msg_id`、`value = act`、TTL 120 秒; +//! 收到设备回执(带 `suc`)时按 `msg_id` + `act` 双条件命中并 `HDEL` +//! +//! 另:协议 §5.7 的"10 小时订单超时"结单由 `workers/order_timeout.rs` 负责, +//! 它**不复位**占用状态,故本模块额外启动周期性对账(见 [`reconcile_usage_loop`])。 use rand::Rng; use redis::aio::ConnectionManager; @@ -13,15 +34,44 @@ use std::sync::LazyLock; use std::time::{Duration, Instant}; use tokio::sync::Mutex; +use crate::error::AppError; use crate::protocol::ServerResponse; +use crate::workers::energy; + +/// 上报队列的轮询间隔(毫秒)。 +/// +/// **为什么用「`RPOP` 非阻塞轮询 + 短 sleep」而不是 `BRPOP`**(2026-09 实测结论): +/// Redis 是经 nginx stream 代理访问阿里云 RDS 的,且客户端用的是多路复用的 +/// `ConnectionManager`。在这种组合下 `BRPOP` 的阻塞语义**不可靠**,实测表现: +/// - `BRPOP key 0`(无限阻塞)→ 代理掐掉空闲连接后**整个消费永久卡死**,且无任何错误日志; +/// - `BRPOP key N`(有限超时)→ **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大, +/// 实测端到端延迟 13~45 秒(协议给的交互窗口才 60 秒,几乎不可用)。 +/// +/// 改为 `RPOP` 轮询后:**延迟稳定在轮询间隔内**,且完全不依赖阻塞命令语义与代理行为。 +/// 每 200ms 一次 `RPOP` 的开销对 Redis 可忽略(单机每秒 5 次)。 +const REDIS_POLL_INTERVAL_MS: u64 = 200; /// Redis key 前缀 const REDIS_KEY_AUTH_STR: &str = "auth_str:"; -/// 签名截取范围 +/// 签名截取范围(8 hex = 4 bytes,受 MCU 硬件协议约束不可改) +/// 审查编号:I5(已确认不修,硬件约束。设备一次只能获取一次 auth_str,除非显式标记重新生成) const SIGN_START: usize = 56; -const SIGN_END: usize = 63; +/// 取 hex[56..64],共 8 位(规范定稿值) +const SIGN_END: usize = 64; /// 签名校验窗口:±60 秒 const TIMESTAMP_TOLERANCE_SECS: i64 = 60; +/// 设备在线 TTL:300 秒内无任意上行即判离线(协议 §2.3) +const DEVICE_ONLINE_TTL_SECS: u32 = 300; +/// 通道状态 hex 长度:硬件 9 字节原样拼接 → **恰好 18 个 hex 字符**(协议 §5.3) +const CHANNEL_HEX_LEN: usize = 18; +/// 仓体级 `id` 的段数:`仓控板-通道`(协议 §3.7,段数必须恰好为 2) +const COMPARTMENT_ID_SEGMENTS: usize = 2; +/// 通道电池信息(`bat_in` 登记)的存活时间:覆盖 10 小时订单超时 + 余量 +const CHANNEL_BAT_TTL_SECS: u32 = 48 * 3600; +/// `cabinet.status1` bit0 = 断电报警位(0 正常 / 1 报警,协议 §3.8) +const CABINET_STATUS1_POWER_FAIL_MASK: u8 = 0x01; +/// 占用状态对账周期:与 `order_timeout` 的订单超时扫描同频(300 秒),避免额外数据库压力 +const RECONCILE_INTERVAL_SECS: u64 = 300; // --------------------------------------------------------------------------- // 频率限制器 @@ -61,16 +111,20 @@ static AUTH_STR_LIMITER: LazyLock = LazyLock::new(|| { pub async fn start(redis: ConnectionManager, mysql: MySqlPool) { tracing::info!("[report_worker] 启动"); + // 占用状态对账 + 「无电池」兜底:`workers/order_timeout.rs` 负责 10 小时超时结单,但**不复位** + // `compartments.status` / `batteries.status`,且停电/离线期间漏报 `bat_out` 的订单需要 + // 靠"通道连续报无电池"提前结单(待确认第 18 项的兜底)—— 按分工这都属于本 worker 的职责。 + tokio::spawn(reconcile_usage_loop(mysql.clone(), redis.clone())); + loop { let mut conn = redis.clone(); - let result: Result<(String, String), redis::RedisError> = redis::cmd("BRPOP") - .arg("device:reports") - .arg(0) - .query_async(&mut conn) - .await; + // 非阻塞轮询取一条上报(原因见 [`REDIS_POLL_INTERVAL_MS`]): + // `LPUSH` 入队 + `RPOP` 出队 = FIFO,与原先 `BRPOP` 的取数顺序一致。 + let result: Result, redis::RedisError> = + redis::cmd("RPOP").arg("device:reports").query_async(&mut conn).await; match result { - Ok((_key, msg_json)) => { + Ok(Some(msg_json)) => { let mysql = mysql.clone(); let redis = redis.clone(); tokio::spawn(async move { @@ -79,8 +133,12 @@ pub async fn start(redis: ConnectionManager, mysql: MySqlPool) { } }); } + // 队列为空:短暂让出,避免空转打满 CPU + Ok(None) => { + tokio::time::sleep(Duration::from_millis(REDIS_POLL_INTERVAL_MS)).await; + } Err(e) => { - tracing::error!("[report_worker] BRPOP 错误: {}", e); + tracing::error!("[report_worker] RPOP 错误: {}", e); tokio::time::sleep(Duration::from_secs(1)).await; } } @@ -99,16 +157,34 @@ async fn process_report( tracing::debug!("[report] 处理 dev_id={} act={}", dev_id, act); + // 协议 §3.5:带 `suc` 的上行是**回执**(平台不再回包),不带 `suc` 才是设备请求 + let is_receipt = msg.get("suc").is_some(); + match act { - "auth_str" => handle_auth_str(&msg, dev_id, redis).await?, - "login" => handle_login(&msg, dev_id, redis).await?, + "auth_str" => handle_auth_str(&msg, dev_id, mysql, redis).await?, + "login" => handle_login(&msg, dev_id, mysql, redis).await?, "status_post" => handle_status_post(&msg, dev_id, mysql, redis).await?, - "pow_fail" => handle_pow_fail(&msg, dev_id, mysql, redis).await?, - "bat_in" => handle_bat_in(&msg, dev_id, mysql).await?, - "bat_out" => handle_bat_out(&msg, dev_id, mysql).await?, - "off" => handle_device_off(&msg, dev_id, mysql).await?, + // 协议定稿:停电是【两条独立指令】,各自对应同名 Redis 字段 + // pow_fail_dc —— 直流停电(接触器分闸 → 仓控板下电,柜控板仍有电) + // pow_fail_ac —— 交流停电(柜控板自身断电,靠板载电容余电抢发最后一条) + "pow_fail_dc" | "pow_fail_ac" => handle_pow_fail(&msg, dev_id, mysql, redis).await?, + // `bat_in` 只登记电池台账,**绝不建单**(协议 §5.5) + "bat_in" => handle_bat_in(&msg, dev_id, mysql, redis).await?, + // `bat_out` 结束订单(`reason='user_stop'`)+ 台账置空闲(协议 §5.6) + "bat_out" => handle_bat_out(&msg, dev_id, mysql, redis).await?, + // `on` / `off` 是**双向 act**:带 `suc` = 对下行指令的回执(只配对、不回包); + // 不带 `suc` = 设备上报(`on` = 建单入口、`off` = 结束订单),平台需回包(协议 §5.7) + "on" | "off" if is_receipt => handle_cmd_result(&msg, dev_id, mysql, redis).await?, + "on" => handle_device_on(&msg, dev_id, mysql, redis).await?, + "off" => handle_device_off(&msg, dev_id, mysql, redis).await?, + // `open` / `pow_on` / `pow_off` / `reset` 的上行都是回执(平台不回包) + "open" | "pow_on" | "pow_off" | "reset" => { + handle_cmd_result(&msg, dev_id, mysql, redis).await?; + } other => { tracing::warn!("[report] 未知上报类型 dev_id={} act={}", dev_id, other); + let resp = ServerResponse::error_for(&msg, "unknown act"); + send_response_to_device(redis, dev_id, &resp).await?; } } @@ -121,7 +197,10 @@ async fn send_response_to_device( dev_id: &str, resp: &ServerResponse, ) -> Result<(), Box> { - let json_str = resp.to_json(); + let mut json_val = serde_json::from_str::(&resp.to_json())?; + // 内部路由字段 `_dev_id` 供 device-server 路由(下发前摘除,设备侧不可见) + json_val[crate::commands::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") @@ -132,80 +211,172 @@ async fn send_response_to_device( Ok(()) } -/// 处理 `auth_str`:生成 8 位随机安全码并存入 Redis +/// 发送 `login` 失败应答,并标记该连接在发送后主动断开。 +/// +/// 规范:**`login` 失败即断开 TCP** —— device-server 发出该报文后关闭连接, +/// 设备需重连并重新走握手流程(重连后先重新申领安全码)。 +/// 注意:`login` 失败**只返回 `invalid signature` 一种**(不区分时间戳/签名/无记录), +/// 属刻意安全设计,避免外部据此试探系统状态。 +/// +/// 内部标记 `_close` 仅用于服务端判断,device-server 下发前会摘除,**设备侧不可见**。 +async fn send_login_failure( + redis: &ConnectionManager, + dev_id: &str, + resp: &ServerResponse, +) -> Result<(), Box> { + let mut json_val = serde_json::from_str::(&resp.to_json())?; + // `_dev_id` 供 device-server 路由;`_close` 指示发送后断开连接 + json_val[crate::commands::ROUTE_DEV_ID_FIELD] = serde_json::json!(dev_id); + json_val["_close"] = serde_json::json!(true); + let json_str = json_val.to_string(); + let mut conn = redis.clone(); + redis::cmd("LPUSH") + .arg("device:commands") + .arg(&json_str) + .query_async::<()>(&mut conn) + .await?; + tracing::info!("[login] 失败应答已推入队列,平台将断开该连接 dev_id={}", dev_id); + Ok(()) +} + +/// 处理 `auth_str`:向设备下发安全码(权威存储为 DB `cabinets.auth_str`,Redis 仅作缓存)。 +/// +/// 两种情况会走到这里: +/// 1. 首次开机申领(DB 无码)→ 生成 8 位随机码 +/// 2. 管理员「重置安全码」后重新申领(`auth_str_issued = 0`)→ 复用 DB 中已登记的新码, +/// 保证接口返回值与设备实际使用值一致 async fn handle_auth_str( msg: &serde_json::Value, dev_id: &str, + mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - let msg_id = msg["msg_id"].as_u64().map(|v| v as u32); if !AUTH_STR_LIMITER.check(dev_id).await { tracing::warn!("[auth_str] 频率超限 dev_id={}", dev_id); - let resp = ServerResponse::error(msg_id, "请求过于频繁,请稍后再试"); + let resp = ServerResponse::error_for(&msg, "rate limit exceeded"); send_response_to_device(redis, dev_id, &resp).await?; return Ok(()); } - let auth_str: String = rand::thread_rng() - .sample_iter(&rand::distributions::Alphanumeric) - .take(8) - .map(char::from) - .collect(); + // 以 DB(cabinets.auth_str)为权威存储,Redis 仅作缓存:读取已登记的安全码与下发标记 + let db_row: Option<(Option, Option)> = + sqlx::query_as("SELECT auth_str, auth_str_issued FROM cabinets WHERE imei = ?") + .bind(dev_id) + .fetch_optional(mysql) + .await?; + // 安全:**设备必须已在 `cabinets` 中登记**。 + // 未登记的 IMEI 若也给它发码,会同时造成两个后果: + // ① DB 那条 `UPDATE ... WHERE imei = ?` 影响 0 行 → 码根本没落库(设备"拿到了却不认") + // ② 码会写进 Redis 缓存 → 后续 `login` 命中缓存直接通过 → **未登记设备可以连上平台** + // 因此这里**静默丢弃**(不回包、不发码、不写 Redis),只记 WARN 便于排查。 + // 合法设备必然已登记(建档时录入 IMEI),所以这条只影响未知设备/攻击流量。 + let (db_code, issued) = match db_row { + Some(row) => row, + None => { + tracing::warn!("[auth_str] 设备未登记,静默丢弃 dev_id={}", dev_id); + return Ok(()); + } + }; + + // 安全码只能下发一次:DB 已标记下发即拒绝,须管理员「重置安全码」后才能重新申领 + if issued == Some(1) { + tracing::warn!("[auth_str] 安全码已下发过,拒绝重复申领 dev_id={}", dev_id); + let resp = ServerResponse::error_for(&msg, "auth_str already issued"); + send_response_to_device(redis, dev_id, &resp).await?; + return Ok(()); + } + + // 复用 DB 中已登记的安全码(权威值):管理员「重置安全码」写入的就是它, + // 保证接口返回值与设备实际使用值一致;DB 无码(首次开机)才新生成。 + let auth_str: String = match db_code.filter(|s| !s.is_empty()) { + Some(code) => { + tracing::info!("[auth_str] 复用已登记安全码 dev_id={}", dev_id); + code + } + None => rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(8) + .map(char::from) + .collect(), + }; + + // 写入 Redis 缓存(权威值在 DB,随后一并写回 DB) let key = format!("{REDIS_KEY_AUTH_STR}{dev_id}"); let mut conn = redis.clone(); - let _: Result<(), _> = redis::cmd("SET") + if let Err(e) = redis::cmd("SET") .arg(&key) .arg(&auth_str) .query_async::<()>(&mut conn) - .await; + .await + { + tracing::error!("[redis] SET auth_str失败 key={} err={}", key, e); + } + + // 标记 DB + Redis(发出后即不可再次申领,须管理员重置) + mark_auth_str_issued(mysql, redis, dev_id, &auth_str).await; tracing::info!("[auth_str] 已生成安全码 dev_id={}", dev_id); - let mut resp = ServerResponse::success(msg_id); + let mut resp = ServerResponse::success_for(&msg); resp.auth_str = Some(auth_str); send_response_to_device(redis, dev_id, &resp).await?; Ok(()) } -/// 处理 `login`:验证签名 +/// 标记安全码已下发:DB(`auth_str_issued=1` + 写回新码)与 Redis 下发标记。 +/// 两者都失败也只记 ERROR —— 设备已收到码,重发请求会被 `auth_str already issued` 拦住。 +async fn mark_auth_str_issued(mysql: &MySqlPool, redis: &ConnectionManager, dev_id: &str, auth_str: &str) { + let db_result: Result = + sqlx::query("UPDATE cabinets SET auth_str_issued = 1, auth_str = ? WHERE imei = ?") + .bind(auth_str) + .bind(dev_id) + .execute(mysql) + .await; + if let Err(e) = db_result { + tracing::error!("[auth_str] 更新 DB 下发标记失败 dev_id={} err={}", dev_id, e); + } + + let issued_key = format!("{}issued:{}", REDIS_KEY_AUTH_STR, dev_id); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("SET") + .arg(&issued_key) + .arg("1") + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] SET issued失败 key={} err={}", issued_key, e); + } +} + +/// 处理 `login`:验证签名,返回仓控板数量 async fn handle_login( msg: &serde_json::Value, dev_id: &str, + mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - let msg_id = msg["msg_id"].as_u64().map(|v| v as u32); let timestamp = msg["timestamp"].as_u64().unwrap_or(0); let sign = msg["sign"].as_str().unwrap_or(""); // 校验时间窗口 ±60 秒 + // 安全设计:时间戳超窗【不单独告知】,对外统一回 invalid signature(见交付文档 §5.2) let now = chrono::Utc::now().timestamp(); let ts = timestamp as i64; if (now - ts).abs() > TIMESTAMP_TOLERANCE_SECS { tracing::warn!("[login] 时间戳超限 dev_id={} timestamp={}", dev_id, timestamp); - let resp = ServerResponse::error(msg_id, "时间戳无效"); - send_response_to_device(redis, dev_id, &resp).await?; + let resp = ServerResponse::error_for(&msg, "invalid signature"); + send_login_failure(redis, dev_id, &resp).await?; return Ok(()); } - // 从 Redis 获取安全码 - let key = format!("{REDIS_KEY_AUTH_STR}{dev_id}"); - let mut conn = redis.clone(); - let auth_str: Option = redis::cmd("GET") - .arg(&key) - .query_async(&mut conn) - .await - .unwrap_or(None); - - let auth_str = match auth_str { - Some(s) => s, - None => { - let resp = ServerResponse::error(msg_id, "未获取安全码,请先调用 auth_str"); - send_response_to_device(redis, dev_id, &resp).await?; - return Ok(()); - } + // 取安全码(Redis 缓存 → DB 权威存储回源)。 + // 完全取不到时 fetch_auth_str 已回复 `invalid signature`,这里直接结束。 + let auth_str: String = match fetch_auth_str(mysql, redis, dev_id, msg).await? { + Some(code) => code, + None => return Ok(()), }; // 计算期望签名 @@ -215,129 +386,623 @@ async fn handle_login( let expected_sign = &hex_hash[SIGN_START..SIGN_END]; if sign == expected_sign { - // 标记设备在线(统一主key,5分钟 TTL) - let redis_key = format!("device:{}", dev_id); - let mut conn = redis.clone(); - let _: Result<(), _> = redis::cmd("HSET") - .arg(&redis_key) - .arg("online").arg("1") - .query_async(&mut conn) - .await; - let _: Result<(), _> = redis::cmd("EXPIRE") - .arg(&redis_key) - .arg(300_u32) - .query_async(&mut conn) - .await; + mark_device_online(redis, dev_id).await; - tracing::info!("[login] 成功 dev_id={}", dev_id); - let resp = ServerResponse::success(msg_id); + // 柜子规格 spec:前 2 位仓控板数 + 后 2 位每板仓数(如 "0606" = 6 板 × 每板 6 仓) + let spec = fetch_spec(mysql, dev_id).await?; + + // 充电工作模式:"0"=平台指令模式(默认)/"1"=自动充电工作模式 + let work_mode = fetch_work_mode(mysql, dev_id).await; + + // ICCID 变更检测:更新 DB + 写 sim_change_logs + sync_iccid(mysql, dev_id, msg).await?; + + tracing::info!( + "[login] 成功 dev_id={} spec={} work_mode={}", + dev_id, + spec, + work_mode + ); + + let mut resp = ServerResponse::success_for(&msg); + resp.spec = Some(spec); + resp.work_mode = Some(work_mode); send_response_to_device(redis, dev_id, &resp).await?; } else { tracing::warn!("[login] 签名不匹配 dev_id={}", dev_id); - let resp = ServerResponse::error(msg_id, "签名错误"); - send_response_to_device(redis, dev_id, &resp).await?; + let resp = ServerResponse::error_for(&msg, "invalid signature"); + send_login_failure(redis, dev_id, &resp).await?; } Ok(()) } -/// 处理状态上报 — 解析status字典,按层级存入Redis -/// Redis结构: -/// device:{imei} — 设备级(online, rssi, pow_fail_dc, pow_fail_ac) -/// device:{imei}:board:{idx} — 板级(status_hex, fault) -/// device:{imei}:board:{idx}:ch:{0-5} — 通道级(on, load, fault, alarm) +/// 取设备安全码:Redis 为缓存,DB `cabinets.auth_str` 为权威存储。 +/// +/// 缓存未命中时回源 DB 并回填缓存,避免因 Redis 故障/清理导致设备永久无法登录。 +/// 完全取不到(该设备从未申领过)→ 回复 `invalid signature` 并返回 `None` +/// —— **不向设备暴露"平台无此设备记录"** 是刻意的安全设计(协议 §5.2)。 +async fn fetch_auth_str( + mysql: &MySqlPool, + redis: &ConnectionManager, + dev_id: &str, + msg: &serde_json::Value, +) -> Result, Box> { + let key = format!("{REDIS_KEY_AUTH_STR}{dev_id}"); + let mut conn = redis.clone(); + let cached: Option = redis::cmd("GET") + .arg(&key) + .query_async(&mut conn) + .await + .unwrap_or(None); + + if let Some(code) = cached.filter(|s| !s.is_empty()) { + return Ok(Some(code)); + } + + let db_row: Option> = sqlx::query_scalar("SELECT auth_str FROM cabinets WHERE imei = ?") + .bind(dev_id) + .fetch_optional(mysql) + .await?; + + match db_row.flatten().filter(|s| !s.is_empty()) { + Some(code) => { + // 回填缓存,后续 login 直接命中 Redis + if let Err(e) = redis::cmd("SET") + .arg(&key) + .arg(&code) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[login] 回填安全码缓存失败 key={} err={}", key, e); + } + tracing::info!("[login] 安全码缓存未命中,已从 DB 回源 dev_id={}", dev_id); + Ok(Some(code)) + } + None => { + tracing::warn!("[login] DB 中无安全码 dev_id={}", dev_id); + let resp = ServerResponse::error_for(msg, "invalid signature"); + send_login_failure(redis, dev_id, &resp).await?; + Ok(None) + } + } +} + +/// 标记设备在线:`device:{imei}` 的 `online=1` + TTL 300 秒 +/// (平台以"300 秒内是否收到过该设备任意报文"判定在线,协议 §2.3) +async fn mark_device_online(redis: &ConnectionManager, dev_id: &str) { + let key = format!("device:{}", dev_id); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("HSET") + .arg(&key) + .arg("online").arg("1") + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] HSET online失败 key={} err={}", key, e); + } + if let Err(e) = redis::cmd("EXPIRE") + .arg(&key) + .arg(DEVICE_ONLINE_TTL_SECS) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] EXPIRE失败 key={} err={}", key, e); + } +} + +/// 该柜规格 `spec`(固定 4 位):前 2 位仓控板数 + 后 2 位每板通道数, +/// 如 `"0606"` = 6 块仓控板 × 每板 6 仓(协议 §5.2 / §10 第 9 条) +async fn fetch_spec(mysql: &MySqlPool, dev_id: &str) -> Result { + let (board_count, compartment_count): (i64, i64) = sqlx::query_as( + "SELECT COUNT(DISTINCT cb.id), COUNT(comp.id) + FROM cabin_boards cb + JOIN cabinets cab ON cb.cabinet_id = cab.id + LEFT JOIN compartments comp ON comp.cabin_board_id = cb.id + WHERE cab.imei = ?", + ) + .bind(dev_id) + .fetch_one(mysql) + .await + .map_err(|e| { + tracing::error!("[login] 查询柜子规格失败 dev_id={} err={}", dev_id, e); + AppError::Internal("查询仓控板数量失败".into()) + })?; + + let per_board = if board_count > 0 { compartment_count / board_count } else { 0 }; + Ok(format!("{:02}{:02}", board_count, per_board)) +} + +/// 读取充电工作模式:`"0"`=平台指令模式(默认)/"1"=自动充电工作模式。 +/// +/// 库中 `cabinets.work_mode` 是 **TINYINT**(0/1/NULL),协议要求应答里是**字符串** `"0"`/`"1"`, +/// 故这里读 `Option` 再转换;NULL 或读取失败按默认 `"0"` 处理并记 WARN,**不阻断登录**。 +async fn fetch_work_mode(mysql: &MySqlPool, dev_id: &str) -> String { + match sqlx::query_scalar::<_, Option>("SELECT work_mode FROM cabinets WHERE imei = ?") + .bind(dev_id) + .fetch_optional(mysql) + .await + { + Ok(v) => match v.flatten() { + Some(1) => "1".to_string(), + _ => "0".to_string(), + }, + Err(e) => { + tracing::warn!( + "[login] 读取 work_mode 失败,按默认 0 处理 dev_id={} err={}", + dev_id, + e + ); + "0".to_string() + } + } +} + +/// ICCID 变更检测:写回 `cabinets.iccid`,变更时记 `sim_change_logs`(协议 §5.2:换 SIM 会被记录) +async fn sync_iccid( + mysql: &MySqlPool, + dev_id: &str, + msg: &serde_json::Value, +) -> Result<(), sqlx::Error> { + let new_iccid = match msg.get("iccid").and_then(|v| v.as_str()) { + Some(v) if !v.is_empty() => v, + _ => return Ok(()), + }; + + // ⚠️ `iccid` 列**可为 NULL**(设备首次上报前它一直是 NULL),所以类型必须是 + // `Option>`:**外层**表示"该 IMEI 的行是否存在",**内层**表示"列是否为 NULL"。 + // 曾写成 `Option`,结果遇到 NULL 时报 + // `decoding column 0: unexpected null; try decoding as an Option` + // 并让**整条 `login` 处理失败** —— 也就是"新建柜子还没上报过 ICCID 就永远登录不上"。 + let old_iccid_row: Option> = + sqlx::query_scalar("SELECT iccid FROM cabinets WHERE imei = ?") + .bind(dev_id) + .fetch_optional(mysql) + .await?; + let old_iccid = old_iccid_row.flatten(); + + match old_iccid { + Some(old) if old == new_iccid => Ok(()), + Some(old) => { + sqlx::query("UPDATE cabinets SET iccid = ? WHERE imei = ?") + .bind(new_iccid) + .bind(dev_id) + .execute(mysql) + .await?; + sqlx::query("INSERT INTO sim_change_logs (imei, old_iccid, new_iccid) VALUES (?, ?, ?)") + .bind(dev_id) + .bind(&old) + .bind(new_iccid) + .execute(mysql) + .await?; + tracing::info!("[login] SIM变更 dev_id={} {} -> {}", dev_id, old, new_iccid); + Ok(()) + } + None => { + sqlx::query("UPDATE cabinets SET iccid = ? WHERE imei = ?") + .bind(new_iccid) + .bind(dev_id) + .execute(mysql) + .await?; + Ok(()) + } + } +} + +// --------------------------------------------------------------------------- +// 通用解析 / Redis key / 日志工具 +// --------------------------------------------------------------------------- + +/// 通道 18 字符 hex 的解析结果(协议 §5.3,多字节一律大端) +#[derive(Debug, Clone, PartialEq)] +struct ChannelStatus { + /// byte0 电池状态(2 位小写 hex,位定义见协议 §3.8) + batt_status: String, + /// byte1 电源状态(2 位小写 hex)—— **与电池状态相互独立**,空仓也照实上报 + pow_status: String, + /// SOC,平台单位 ‰(硬件 1% → ×10) + soc: i64, + /// 充电电压,平台单位 mV(硬件 0.01V → ×10) + voltage: i64, + /// 充电电流,平台单位 mA(硬件 0.01A → ×10) + current: i64, + /// 电芯最高温度,平台单位 K×100(硬件 1K → ×100) + temp: i64, +} + +/// 该通道最近一次 `bat_in` 登记的电池信息(Redis 内部键)。 +/// +/// 为什么需要它:协议里 `on` 报文**不带 `info`**,起始 SOC 与电池 id 只能回溯 `bat_in`(§5.7)。 +#[derive(Debug, Clone)] +pub struct ChannelBattery { + /// 电池序列号;**可能为空串**(BMS 读不到序列号,不影响充电) + pub battery_id: String, + /// 起始 SOC(‰) + pub soc: i64, +} + +/// 解析通道的 18 字符 hex(协议 §5.3 字节布局): +/// byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) / byte3-4 电压(0.01V, 大端) +/// / byte5-6 电流(0.01A, 大端) / byte7-8 电芯最高温度(1K, 大端)。 +/// +/// 长度不是 18、含非 hex 字符(大小写不敏感)或字段缺失 → 返回 `None`, +/// 由调用方记 WARN 并跳过该通道,**绝不 panic**。 +fn parse_channel_hex(raw: &str) -> Option { + if raw.len() != CHANNEL_HEX_LEN { + return None; + } + // 按字节取 2 个 hex 字符 → u8;非 hex 或越界(非 ASCII 边界)返回 None + let byte = |idx: usize| -> Option { + u8::from_str_radix(raw.get(idx * 2..idx * 2 + 2)?, 16).ok() + }; + let be16 = |idx: usize| -> Option { + Some(u16::from_be_bytes([byte(idx)?, byte(idx + 1)?])) + }; + + Some(ChannelStatus { + batt_status: format!("{:02x}", byte(0)?), + pow_status: format!("{:02x}", byte(1)?), + soc: i64::from(byte(2)?) * 10, + voltage: i64::from(be16(3)?) * 10, + current: i64::from(be16(5)?) * 10, + temp: i64::from(be16(7)?) * 100, + }) +} + +/// 校验并规范化 2 位 hex 状态字节(协议 §3.8);缺失或非法 → `None`(调用方记 WARN) +fn normalize_hex_byte(raw: Option<&str>) -> Option { + let raw = raw?; + if raw.len() != 2 { + return None; + } + u8::from_str_radix(raw, 16).ok().map(|b| format!("{:02x}", b)) +} + +/// 解析仓体级 `id`(`仓控板-通道`,**段数必须恰好为 2**,协议 §3.7)→ (仓控板号, 通道号)。 +/// +/// 仓控板 1–15、通道 1–8;`03` 这类补零写法兼容,段数/数字非法 → `None`(调用方回 `invalid id`)。 +fn parse_compartment_id(msg: &serde_json::Value) -> Option<(i8, i8)> { + let id = msg.get("id").and_then(|v| v.as_str())?; + let parts: Vec<&str> = id.split('-').collect(); + if parts.len() != COMPARTMENT_ID_SEGMENTS { + return None; + } + let board_id: i8 = parts.first()?.trim().parse().ok()?; + let channel_id: i8 = parts.get(1)?.trim().parse().ok()?; + if board_id < 1 || channel_id < 1 { + return None; + } + Some((board_id, channel_id)) +} + +/// 按(IMEI、仓控板号、通道号)查仓体 → (仓体 ID, 柜子 ID)。 +/// 查不到返回 `None`(协议非法目标,应答 `invalid id`);DB 故障返回 `Err`(由调用方记 ERROR 后上抛)。 +async fn resolve_compartment( + mysql: &MySqlPool, + dev_id: &str, + board_id: i8, + channel_id: i8, +) -> Result, sqlx::Error> { + sqlx::query_as( + "SELECT comp.id, cab.id FROM cabinets cab + JOIN cabin_boards cb ON cb.cabinet_id = cab.id + JOIN compartments comp ON comp.cabin_board_id = cb.id + WHERE cab.imei = ? AND cb.board_id = ? AND comp.channel_id = ?", + ) + .bind(dev_id) + .bind(board_id) + .bind(channel_id) + .fetch_optional(mysql) + .await +} + +/// 解析 `id` 并定位仓体:成功 → `(仓控板号, 通道号, 仓体 ID, 柜子 ID)`。 +/// +/// **任一步失败都会回复 `invalid id` 并返回 `None`**(调用方直接 `return Ok(())`), +/// 避免四个仓体级处理函数各写一遍相同的校验分支。 +/// 注意:DB 故障**不**回 `invalid id`(会误导设备"修正格式后重发"),只记 ERROR 后上抛给外层日志兜底。 +async fn resolve_target( + msg: &serde_json::Value, + dev_id: &str, + mysql: &MySqlPool, + redis: &ConnectionManager, + tag: &str, +) -> Result, Box> { + let (board_id, channel_id) = match parse_compartment_id(msg) { + Some(v) => v, + None => { + tracing::warn!("[{}] id 段数非法 dev_id={} id={}", tag, dev_id, msg["id"]); + let resp = ServerResponse::error_for(msg, "invalid id"); + send_response_to_device(redis, dev_id, &resp).await?; + return Ok(None); + } + }; + + let resolved = resolve_compartment(mysql, dev_id, board_id, channel_id) + .await + .map_err(|e| { + tracing::error!("[{}] 查询仓体失败 dev_id={} err={}", tag, dev_id, e); + e + })?; + + match resolved { + Some((compartment_id, cabinet_id)) => { + Ok(Some((board_id, channel_id, compartment_id, cabinet_id))) + } + None => { + tracing::warn!( + "[{}] id 对应仓体不存在 dev_id={} board={} ch={}", + tag, + dev_id, + board_id, + channel_id + ); + let resp = ServerResponse::error_for(msg, "invalid id"); + send_response_to_device(redis, dev_id, &resp).await?; + Ok(None) + } + } +} + +/// 通道级实时状态键:`device:{imei}:board:{板号}:ch:{通道号}`(板号/通道号十进制不补零) +fn channel_status_key(imei: &str, board_id: i64, channel_id: i64) -> String { + format!("device:{}:board:{}:ch:{}", imei, board_id, channel_id) +} + +/// 通道电池信息键(`bat_in` 登记,内部键) +fn channel_battery_key(imei: &str, board_id: i8, channel_id: i8) -> String { + format!("device:{}:board:{}:ch:{}:bat", imei, board_id, channel_id) +} + +/// 已下发指令的挂起项键(device-server 下发时写入,收到回执时 HDEL 配对) +fn pending_key(imei: &str) -> String { + format!("pending:{}", imei) +} + +/// 写 `device_logs`。失败只记 ERROR、不阻断业务: +/// **平台日志写失败不该让设备重发业务报文**(协议 §3.6:超时 ≠ 未执行)。 +async fn insert_device_log(mysql: &MySqlPool, dev_id: &str, log_type: i64, content: serde_json::Value) { + let result: Result = sqlx::query( + "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( + (SELECT id FROM cabinets WHERE imei = ?), ?, ? + )", + ) + .bind(dev_id) + .bind(log_type) + .bind(content.to_string()) + .execute(mysql) + .await; + if let Err(e) = result { + tracing::error!("[log] device_logs 写入失败 dev_id={} err={}", dev_id, e); + } +} + +// --------------------------------------------------------------------------- +// status_post +// --------------------------------------------------------------------------- + +/// 处理 `status_post` —— 周期全量状态上报(协议 §5.3)。 +/// +/// 写入契约见模块头注释;`boards` 缺失或非数组时记 WARN 但**仍回 `suc:1`** +/// (状态缺失是设备侧问题,不应因平台解析失败让设备重发)。 async fn handle_status_post( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - let mut conn = redis.clone(); - let device_key = format!("device:{}", dev_id); - - // 设置设备在线 + RSSI - let rssi = msg["rssi"].as_i64().unwrap_or(0); - let _: Result<(), _> = redis::cmd("HSET") - .arg(&device_key) - .arg("online").arg("1") - .arg("rssi").arg(rssi.to_string()) - .query_async(&mut conn) - .await; - - // 设置TTL 300秒 - let _: Result<(), _> = redis::cmd("EXPIRE") - .arg(&device_key) - .arg(300_u32) - .query_async(&mut conn) - .await; + let cabinet = msg.get("cabinet"); + let rssi = msg.get("rssi").and_then(|v| v.as_i64()).unwrap_or(0); + let temp = cabinet.and_then(|c| c.get("temp")).and_then(|v| v.as_i64()).unwrap_or(0); + let meter = cabinet.and_then(|c| c.get("meter")).and_then(|v| v.as_i64()).unwrap_or(0); - // 解析 status 字典,按层级存入 - if let Some(status_obj) = msg["status"].as_object() { - for (key, value) in status_obj { - // key格式: "IMEI-仓控板ID" → board_idx - let parts: Vec<&str> = key.split('-').collect(); - if parts.len() < 2 { - continue; - } - let board_idx = parts[parts.len() - 1]; // 取最后一段作为板号 - - // 存板级状态(原始hex) - let board_key = format!("device:{}:board:{}", dev_id, board_idx); - let _: Result<(), _> = redis::cmd("HSET") - .arg(&board_key) - .arg("status_hex").arg(value.as_str().unwrap_or("")) - .query_async(&mut conn) - .await; - - // 解析6个通道,每个通道4个hex字符(2字节) - let hex_str = value.as_str().unwrap_or(""); - for ch in 0..6 { - let start = ch * 8; // 每通道8个hex字符 = 4字节 - if start + 8 > hex_str.len() { - break; - } - let ch_hex = &hex_str[start..start + 8]; - - // 每通道4字节,按协议解析 - // 字节1: bit0=在线, bit1=充电中, bit2=充满, bit3=故障 - // 字节2: 负载类型(预留) - // 字节3-4: 预留 - let byte1 = u8::from_str_radix(&ch_hex[0..2], 16).unwrap_or(0); - - let ch_key = format!("device:{}:board:{}:ch:{}", dev_id, board_idx, ch); - let _: Result<(), _> = redis::cmd("HSET") - .arg(&ch_key) - .arg("on").arg(if byte1 & 0x02 != 0 { "1" } else { "0" }) - .arg("full").arg(if byte1 & 0x04 != 0 { "1" } else { "0" }) - .arg("fault").arg(if byte1 & 0x08 != 0 { "1" } else { "0" }) - .arg("hex").arg(ch_hex) - .query_async(&mut conn) - .await; - } - } + let raw_status1 = cabinet.and_then(|c| c.get("status1")).and_then(|v| v.as_str()); + let raw_status2 = cabinet.and_then(|c| c.get("status2")).and_then(|v| v.as_str()); + let status1 = normalize_hex_byte(raw_status1); + let status2 = normalize_hex_byte(raw_status2); + if cabinet.is_none() || status1.is_none() || status2.is_none() { + tracing::warn!( + "[status_post] 柜体状态缺失或非法 dev_id={} status1={:?} status2={:?}", + dev_id, + raw_status1, + raw_status2 + ); + } + + // 1) 设备级 Hash:在线标记 + 信号 + 柜体状态(TTL 300 秒) + write_device_state(redis, dev_id, rssi, status1.as_deref(), status2.as_deref(), temp, meter).await; + + // 2) 供电恢复即清除停电标记:`cabinet.status1` bit0 = 0(正常)→ pow_fail_dc/ac 置 0; + // bit0 = 1 时**不动**(停电事件由 pow_fail_dc / pow_fail_ac 单独置 1,协议 §5.4) + clear_power_fail_if_normal(redis, dev_id, raw_status1).await; + + // 3) 逐板逐通道写入(数组下标 = 通道号 - 1) + match msg.get("boards").and_then(|v| v.as_array()) { + Some(boards) => { + for board in boards { + write_board_channels(redis, dev_id, board).await; + } + } + None => tracing::warn!("[status_post] boards 缺失或非数组 dev_id={}", dev_id), } - // 持久化到 MySQL let content = serde_json::json!({ "dev_id": dev_id, - "rssi": msg["rssi"], - "status": msg["status"], - "timestamp": msg["timestamp"], + "act": "status_post", + "rssi": rssi, + "cabinet": cabinet, + "board_count": msg.get("boards").and_then(|v| v.as_array()).map(|b| b.len()).unwrap_or(0), }); - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 1, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + insert_device_log(mysql, dev_id, 1, content).await; + + tracing::debug!("[status_post] 已存储 dev_id={}", dev_id); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; - tracing::debug!("[status_post] 已解析并存储 dev_id={}", dev_id); Ok(()) } +/// 写设备级 Hash `device:{imei}`(TTL 300 秒):`online` / `rssi` / 柜体状态 / 温度 / 电表。 +async fn write_device_state( + redis: &ConnectionManager, + dev_id: &str, + rssi: i64, + status1: Option<&str>, + status2: Option<&str>, + temp: i64, + meter: i64, +) { + let key = format!("device:{}", dev_id); + let mut conn = redis.clone(); + let mut cmd = redis::cmd("HSET"); + cmd.arg(&key) + .arg("online").arg("1") + .arg("rssi").arg(rssi.to_string()) + .arg("temp").arg(temp.to_string()) + .arg("meter").arg(meter.to_string()); + if let Some(s1) = status1 { + cmd.arg("cabinet_status1").arg(s1); + } + if let Some(s2) = status2 { + cmd.arg("cabinet_status2").arg(s2); + } + if let Err(e) = cmd.query_async::<()>(&mut conn).await { + tracing::error!("[redis] HSET 设备状态失败 key={} err={}", key, e); + } + // TTL 同时承担"在线判定":300 秒无上行即判离线(协议 §2.3) + if let Err(e) = redis::cmd("EXPIRE") + .arg(&key) + .arg(DEVICE_ONLINE_TTL_SECS) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] EXPIRE 设备状态失败 key={} err={}", key, e); + } +} + +/// 供电恢复检测:`cabinet.status1` bit0 = 0(正常)→ 清除 `pow_fail_dc` / `pow_fail_ac`。 +/// +/// 为什么只在 bit0=0 时清:bit0=1 表示仍在报警,此时若清标记会把刚置位的停电状态抹掉。 +async fn clear_power_fail_if_normal(redis: &ConnectionManager, dev_id: &str, raw_status1: Option<&str>) { + let byte = match raw_status1.and_then(|s| u8::from_str_radix(s, 16).ok()) { + Some(b) => b, + None => { + tracing::warn!("[status_post] 无有效 status1,跳过停电状态清除 dev_id={}", dev_id); + return; + } + }; + if byte & CABINET_STATUS1_POWER_FAIL_MASK != 0 { + return; // bit0 = 1:仍在断电报警,保持停电标记 + } + + let key = format!("device:{}", dev_id); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("HSET") + .arg(&key) + .arg("pow_fail_dc").arg("0") + .arg("pow_fail_ac").arg("0") + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] 清除停电标记失败 key={} err={}", key, e); + } else { + tracing::debug!("[status_post] 供电正常,已清除停电标记 dev_id={}", dev_id); + } +} + +/// 写入一块仓控板的全部通道状态;单个通道 hex 非法只跳过该通道并记 WARN,不影响其余通道。 +async fn write_board_channels(redis: &ConnectionManager, dev_id: &str, board: &serde_json::Value) { + let board_no = match board.get("board").and_then(|v| v.as_i64()) { + Some(v) => v, + None => { + tracing::warn!("[status_post] 板号缺失或非法 dev_id={} board={}", dev_id, board); + return; + } + }; + let channels = match board.get("channels").and_then(|v| v.as_array()) { + Some(v) => v, + None => { + tracing::warn!("[status_post] channels 缺失或非数组 dev_id={} board={}", dev_id, board_no); + return; + } + }; + + for (idx, raw) in channels.iter().enumerate() { + let hex = match raw.as_str() { + Some(v) => v, + None => { + tracing::warn!("[status_post] 通道状态非字符串 dev_id={} board={}", dev_id, board_no); + continue; + } + }; + let status = match parse_channel_hex(hex) { + Some(v) => v, + None => { + tracing::warn!( + "[status_post] 通道 hex 非法(需 18 位 hex)dev_id={} board={} idx={} hex={}", + dev_id, + board_no, + idx, + hex + ); + continue; + } + }; + // 数组下标 = 通道号 - 1(协议 §5.3:第 1 个元素 = 通道 1) + let channel_no = idx as i64 + 1; + write_channel_state(redis, dev_id, board_no, channel_no, &status).await; + } +} + +/// 写通道级 Hash `device:{imei}:board:{板号}:ch:{通道号}`(字段契约见模块头注释) +async fn write_channel_state( + redis: &ConnectionManager, + dev_id: &str, + board_no: i64, + channel_no: i64, + status: &ChannelStatus, +) { + let key = channel_status_key(dev_id, board_no, channel_no); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("HSET") + .arg(&key) + .arg("batt_status").arg(&status.batt_status) + .arg("pow_status").arg(&status.pow_status) + .arg("soc").arg(status.soc.to_string()) + .arg("voltage").arg(status.voltage.to_string()) + .arg("current").arg(status.current.to_string()) + .arg("temp").arg(status.temp.to_string()) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] HSET 通道状态失败 key={} err={}", key, e); + } + + // 订单电量积分(**待确认第 13 项的兜底方案**):按 电压 × 电流 × 时间 累加到该通道的 + // 订单累加器;同时跟踪本次充电最高温与"连续无电池"采样次数 + // (后者供待确认第 18 项的状态兜底:bit7 连续归零 ⇒ 判定电池已被取走)。 + // 放在这里是因为这是"每次 status_post 都能拿到电压/电流/温度/电池位"的唯一入口。 + let battery_present = u8::from_str_radix(&status.batt_status, 16) + .map(|b| b & 0x80 != 0) + .unwrap_or(true); + energy::accumulate( + redis, + dev_id, + board_no, + channel_no, + status.voltage, + status.current, + status.temp, + battery_present, + ) + .await; +} + +// --------------------------------------------------------------------------- +// 停电上报 +// --------------------------------------------------------------------------- + /// 处理停电上报 async fn handle_pow_fail( msg: &serde_json::Value, @@ -345,129 +1010,1451 @@ async fn handle_pow_fail( mysql: &MySqlPool, redis: &ConnectionManager, ) -> Result<(), Box> { - let info = msg["info"].as_str().unwrap_or("0"); - + // 停电类型由 act 本身承载(两条独立指令),不再使用 info 字段 + let act = msg["act"].as_str().unwrap_or(""); + let field = match act { + "pow_fail_dc" => "pow_fail_dc", + "pow_fail_ac" => "pow_fail_ac", + other => { + tracing::warn!("[pow_fail] 未知停电类型 act={} dev_id={}", other, dev_id); + return Ok(()); + } + }; + // 更新Redis Hash(统一主key) let redis_key = format!("device:{}", dev_id); let mut conn = redis.clone(); - - if info == "0" { - // 直流停电 - let _: Result<(), _> = redis::cmd("HSET") - .arg(&redis_key) - .arg("pow_fail_dc").arg("1") - .query_async(&mut conn) - .await; - } else if info == "1" { - // 交流停电 - let _: Result<(), _> = redis::cmd("HSET") - .arg(&redis_key) - .arg("pow_fail_ac").arg("1") - .query_async(&mut conn) - .await; + + if let Err(e) = redis::cmd("HSET") + .arg(&redis_key) + .arg(field) + .arg("1") + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] HSET {} 失败 key={} err={}", field, redis_key, e); } // 同时存入device_logs let content = serde_json::json!({ "dev_id": dev_id, - "info": info, + "act": act, "timestamp": msg["timestamp"], "sub_device_id": msg["sub_device_id"], }); + insert_device_log(mysql, dev_id, 4, content).await; - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 4, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + tracing::info!("[pow_fail] 停电上报 dev_id={} act={}", dev_id, act); + + // 回复设备 + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; - tracing::info!("[pow_fail] 停电上报 dev_id={} info={}", dev_id, info); Ok(()) } -/// 处理电池插入 +// --------------------------------------------------------------------------- +// 通道电池信息(bat_in 登记,供 on 建单取电池 id / 起始 SOC) +// --------------------------------------------------------------------------- + +/// 写该通道最近一次 `bat_in` 的电池信息(内部键,TTL 48 小时)。 +/// +/// 为什么单独存:协议里 `on` 报文**不带 `info`**,起始 SOC 只能回溯 `bat_in`(§5.7); +/// 而 `status_post` 的通道状态是**周期性**的,无法区分"插入首帧"。 +async fn save_channel_battery( + redis: &ConnectionManager, + dev_id: &str, + board_id: i8, + channel_id: i8, + battery: Option<&serde_json::Value>, + battery_id: &str, + soc: i64, +) { + let text = |name: &str| -> String { + battery + .and_then(|b| b.get(name)) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + }; + let number = |name: &str| -> String { + battery + .and_then(|b| b.get(name)) + .and_then(|v| v.as_i64()) + .unwrap_or(0) + .to_string() + }; + + let key = channel_battery_key(dev_id, board_id, channel_id); + let mut conn = redis.clone(); + let result: Result<(), redis::RedisError> = redis::cmd("HSET") + .arg(&key) + .arg("battery_id").arg(battery_id) + .arg("soc").arg(soc.to_string()) + .arg("info").arg(text("info")) + .arg("brand_code").arg(text("brand_code")) + .arg("model_code").arg(text("model_code")) + .arg("material_code").arg(text("material_code")) + .arg("cell_count").arg(number("cell_count")) + .arg("rated_voltage").arg(number("rated_voltage")) + .arg("rated_capacity").arg(number("rated_capacity")) + .query_async(&mut conn) + .await; + + if let Err(e) = result { + tracing::error!("[redis] HSET 通道电池信息失败 key={} err={}", key, e); + return; + } + if let Err(e) = redis::cmd("EXPIRE") + .arg(&key) + .arg(CHANNEL_BAT_TTL_SECS) + .query_async::<()>(&mut conn) + .await + { + tracing::error!("[redis] EXPIRE 通道电池信息失败 key={} err={}", key, e); + } +} + +/// 读取该通道最近一次 `bat_in` 登记的电池信息。 +/// +/// 供设备上报 `on`(自动充电模式)与平台下发 `on`(H5 建单)取电池 id 与起始 SOC。 +/// 该通道**从未有 `bat_in` 登记**(或 Redis 故障)→ 返回 `None`,由调用方决定兜底策略。 +pub async fn load_channel_battery( + redis: &ConnectionManager, + imei: &str, + board_id: i8, + channel_id: i8, +) -> Option { + let key = channel_battery_key(imei, board_id, channel_id); + let mut conn = redis.clone(); + let values: Result>, redis::RedisError> = redis::cmd("HMGET") + .arg(&key) + .arg(&["battery_id", "soc"]) + .query_async(&mut conn) + .await; + + match values { + Ok(values) => { + // 键不存在 → 两个字段都是 None → 视为"无登记"; + // 注意 battery_id 可能是**存在的空串**(BMS 读不到序列号),那是有效登记 + let battery_id = values.first().cloned().flatten()?; + let soc = values + .get(1) + .cloned() + .flatten() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + Some(ChannelBattery { battery_id, soc }) + } + Err(e) => { + tracing::warn!("[redis] 读取通道电池信息失败 key={} err={}", key, e); + None + } + } +} + +/// 清除通道电池登记(电池已拔出) +async fn delete_channel_battery(redis: &ConnectionManager, dev_id: &str, board_id: i8, channel_id: i8) { + let key = channel_battery_key(dev_id, board_id, channel_id); + let mut conn = redis.clone(); + if let Err(e) = redis::cmd("DEL").arg(&key).query_async::(&mut conn).await { + tracing::error!("[redis] DEL 通道电池信息失败 key={} err={}", key, e); + } +} + +// --------------------------------------------------------------------------- +// 充电订单(建单唯一入口 = on,协议 §5.7) +// --------------------------------------------------------------------------- + +/// 建单判定的结论(纯逻辑,便于单测覆盖"一个通道只可能有一块电池"这条核心规则) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OrderDecision { + /// 无活跃订单 → 新建 + Create, + /// 已有活跃订单且电池未变(含任一方 id 为空串)→ 不新建 + Skip, + /// 已有活跃订单但换电池了 → 老订单强制结束后再新建 + Replace, +} + +/// 纯函数:根据「当前活跃订单的电池 id」与「本次要充电的电池 id」决定建单动作。 +/// +/// 规则依据交付文档 §5.5 / §5.7: +/// - **一个通道只可能有一块电池** —— 所以 id 变了必然是换了新电池,老订单必须先结束; +/// - **任一方为空串**(BMS 读不到序列号)→ **视为未变**,不新建(否则每次重复上报都会多一笔订单); +/// - 双方 id 相同 → 同一次充电的重复上报(设备重发 `on`)→ 不新建。 +/// +/// 抽成纯函数的原因:这段判定是**订单生命周期里最容易出错、又最需要被证明正确**的一环, +/// 而它原本内嵌在"查库 + 结单 + 建单"的异步流程里,无法单测。 +pub fn decide_order_action(active_battery: Option<&str>, new_battery: &str) -> OrderDecision { + let Some(old) = active_battery else { + return OrderDecision::Create; + }; + // 任一方未知(空串)⇒ 无法证明换了电池 ⇒ 保守视为未变 + if old.is_empty() || new_battery.is_empty() { + return OrderDecision::Skip; + } + if old == new_battery { + OrderDecision::Skip + } else { + OrderDecision::Replace + } +} + +/// 确保该通道有一笔活跃充电订单(**协议里建单的唯一入口是 `on`**,见 §5.7)。 +/// 建单判定见 [`decide_order_action`](依据"一个通道只可能有一块电池")。 +/// +/// `start_soc` 为 `None` 时回读该通道 `bat_in` 登记的 SOC 兜底,仍拿不到则记 0; +/// 平台下发 `on`(`routes/h5.rs`)与设备上报 `on`(自动充电模式)**共用本函数**。 +pub async fn ensure_charge_order( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + cabinet_id: i64, + board_id: i8, + channel_id: i8, + battery_id: &str, + start_soc: Option, +) -> Result<(), Box> { + let compartment_id = match find_compartment_id(pool, cabinet_id, board_id, channel_id).await? { + Some(id) => id, + None => { + tracing::warn!("[order] 仓体不存在,跳过建单 cabinet_id={} board={} ch={}", cabinet_id, board_id, channel_id); + return Ok(()); + } + }; + + let active: Option<(i64, String)> = sqlx::query_as( + "SELECT id, battery_id FROM charge_orders + WHERE compartment_id = ? AND status = 0 ORDER BY id DESC LIMIT 1", + ) + .bind(compartment_id) + .fetch_optional(pool) + .await?; + + // 是否需要"换电池"处理:两边电池 id 都已知且不同(任一为空视为未变) + let decision = decide_order_action(active.as_ref().map(|(_, b)| b.as_str()), battery_id); + + match &active { + None => {} + Some(_) if decision == OrderDecision::Skip => { + tracing::debug!( + "[order] 通道已有活跃订单且电池未变,不新建 compartment_id={}", + compartment_id + ); + return Ok(()); + } + Some((order_id, old_battery)) => { + // 一个通道只可能有一块电池:id 变了必然是换了新电池,老订单必须先结束。 + // 走统一结算:写回本次充电电量(平台侧积分)并生成充电记录。 + finalize_order( + pool, + redis, + imei, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + None, + 2, + "forced", + RECORD_STATUS_ABNORMAL, + ) + .await?; + // 老订单结束 ⇒ 老电池台账复位为空闲(新订单随后把新电池置"使用中") + if !old_battery.is_empty() { + mark_battery_status(pool, old_battery, 0).await; + } + tracing::warn!( + "[order] 通道换电池,老订单强制结束 order_id={} {} -> {} compartment_id={}", + order_id, + old_battery, + battery_id, + compartment_id + ); + } + } + + let start_soc = match start_soc { + Some(v) => v, + None => resolve_start_soc(pool, redis, cabinet_id, board_id, channel_id).await, + }; + + insert_charge_order( + pool, + redis, + imei, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + cabinet_id, + battery_id, + start_soc, + ) + .await?; + + tracing::info!( + "[order] 已建充电订单 compartment_id={} battery_id={} start_soc={}", + compartment_id, + battery_id, + start_soc + ); + + Ok(()) +} + +/// 插入一笔进行中的充电订单(`status=0`),并把该仓体/电池标记为**占用**: +/// `compartments.status=1`(充电中)、`batteries.status=1`(使用中)。 +/// 占用状态必须在订单结束时复位,见 [`mark_compartment_status`] 与 [`reconcile_usage_loop`]。 +/// +/// 同时**清零该通道的电量累加器**(`energy::reset`)—— 保证本单积分从 0 开始, +/// 不会把上一单的余量算进来(累加器是"通道维度"的,靠这里划清每单边界)。 +async fn insert_charge_order( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, + compartment_id: i64, + cabinet_id: i64, + battery_id: &str, + start_soc: i64, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO charge_orders (compartment_id, cabinet_id, battery_id, start_time, start_soc, status) + VALUES (?, ?, ?, NOW(), ?, 0)", + ) + .bind(compartment_id) + .bind(cabinet_id) + .bind(battery_id) + .bind(start_soc) + .execute(pool) + .await?; + energy::reset(redis, imei, board_id, channel_id).await; + mark_compartment_status(pool, compartment_id, 1).await; + if !battery_id.is_empty() { + mark_battery_status(pool, battery_id, 1).await; + } + Ok(()) +} + +/// 充电记录 `status` 取值(与 `charge_records.status` 注释一致) +pub const RECORD_STATUS_NORMAL: i8 = 0; +/// 充电记录:异常结束(换电池强制结束 / 定超时兜底) +pub const RECORD_STATUS_ABNORMAL: i8 = 1; +/// 充电记录:手动停止(用户主动拔电池) +pub const RECORD_STATUS_MANUAL_STOP: i8 = 2; + +/// **统一结算订单**:写回电量、生成充电记录、复位占用状态。**所有"订单结束"的路径都必须走它**。 +/// +/// 为什么统一:`energy` 只能在这里落库 —— 平台侧按 `电压 × 电流 × 时间` 积分 +/// (**待确认第 13 项的兜底方案**,见 [`crate::workers::energy`])。若某条结束路径漏了它, +/// 那条订单的电量就永远是 0,且充电记录页/小程序统计会缺一条。 +/// +/// 步骤: +/// 1. 从 Redis 累加器**取出并清零**本次充电的电量与最高温(原子,见 `energy::take_and_reset`) +/// 2. `UPDATE charge_orders` 收尾(带 `status = 0` 条件,避免与其它路径重复结算) +/// 3. 仅当本次真的改动了行(`rows_affected > 0`)才 `INSERT charge_records`, +/// 从而**天然幂等**:重复调用不会产生重复记录 +/// +/// `end_soc` 为 `None` 时保持库中原值不变(用 `COALESCE` 语义写在 SQL 里)。 +async fn finalize_order( + pool: &MySqlPool, + redis: &ConnectionManager, + imei: &str, + board_id: i64, + channel_id: i64, + compartment_id: i64, + end_soc: Option, + order_status: i8, + reason: &str, + record_status: i8, +) -> Result { + // 1) 结算电量(平台侧积分结果) + let (mwh, max_temp) = energy::take_and_reset(redis, imei, board_id, channel_id).await; + let energy_wh = energy::mwh_to_wh(mwh); + + // 2) 找到该仓体当前活跃订单(若无则说明已被别的路径结算,直接返回) + let order_id: Option = sqlx::query_scalar( + "SELECT id FROM charge_orders WHERE compartment_id = ? AND status = 0 \ + ORDER BY id DESC LIMIT 1", + ) + .bind(compartment_id) + .fetch_optional(pool) + .await?; + let Some(order_id) = order_id else { + return Ok(0); + }; + + let affected = sqlx::query( + "UPDATE charge_orders SET status = ?, end_time = NOW(), + end_soc = COALESCE(?, end_soc), energy = ?, reason = ? + WHERE id = ? AND status = 0", + ) + .bind(order_status) + .bind(end_soc) + .bind(energy_wh) + .bind(reason) + .bind(order_id) + .execute(pool) + .await? + .rows_affected(); + + if affected == 0 { + return Ok(0); + } + + // 3) 生成充电记录(供后台「充电记录」页 / 导出 / 小程序今日统计使用) + // 字段从订单行直接 SELECT 过来,避免再查一次仓体与柜子 + if let Err(e) = sqlx::query( + "INSERT INTO charge_records + (compartment_id, cabinet_id, start_time, end_time, energy, max_temperature, status) + SELECT compartment_id, cabinet_id, start_time, end_time, ?, ?, ? + FROM charge_orders WHERE id = ?", + ) + .bind(energy_wh) + .bind(max_temp) + .bind(record_status) + .bind(order_id) + .execute(pool) + .await + { + // 记录缺失只记 ERROR:订单已经正确收尾,不能因为记录写失败而回滚/重试整笔业务 + tracing::error!( + "[order] 充电记录写入失败 order_id={} err={}(订单已正常收尾)", + order_id, + e + ); + } + + tracing::info!( + "[order] 订单结算 order_id={} reason={} energy={}Wh(积分 {}mWh)最高温={}", + order_id, + reason, + energy_wh, + mwh, + max_temp + ); + + Ok(affected) +} + +/// 复位 / 置位仓体占用状态(0 空闲 / 1 充电中)。失败只记 ERROR,不影响设备流程。 +async fn mark_compartment_status(pool: &MySqlPool, compartment_id: i64, status: i8) { + let result: Result = + sqlx::query("UPDATE compartments SET status = ? WHERE id = ?") + .bind(status) + .bind(compartment_id) + .execute(pool) + .await; + if let Err(e) = result { + tracing::error!( + "[order] 仓体占用状态更新失败 compartment_id={} status={} err={}", + compartment_id, + status, + e + ); + } +} + +/// 复位 / 置位电池台账占用状态(0 空闲 / 1 使用中)。失败只记 ERROR,不影响设备流程。 +async fn mark_battery_status(pool: &MySqlPool, battery_id: &str, status: i8) { + let result: Result = + sqlx::query("UPDATE batteries SET status = ? WHERE battery_id = ?") + .bind(status) + .bind(battery_id) + .execute(pool) + .await; + if let Err(e) = result { + tracing::error!( + "[order] 电池台账状态更新失败 battery_id={} status={} err={}", + battery_id, + status, + e + ); + } +} + +/// 周期性对账:把**有已结束订单、且当前没有活跃订单**的仓体/电池复位为空闲。 +/// +/// 为什么需要它:10 小时超时结单(`order_timeout`)与停电/离线期间漏报的结束事件都不会 +/// 走到 `bat_out` / `off` 的复位分支,若不兜底,仓体会永远显示"充电中"。 +/// 为什么加 `EXISTS(已结束订单)` 条件:**刚 `bat_in` 还未开始充电**的仓体/电池 +/// (`status=1` 但一笔订单都没有)不应被误复位,那属于正常的"已插入、待充电"状态。 +async fn reconcile_usage_loop(mysql: MySqlPool, redis: ConnectionManager) { + let mut ticker = tokio::time::interval(Duration::from_secs(RECONCILE_INTERVAL_SECS)); + loop { + ticker.tick().await; + if let Err(e) = reconcile_usage_once(&mysql, &redis).await { + tracing::error!("[reconcile] 占用状态对账失败 err={}", e); + } + } +} + +/// 单轮对账(见 [`reconcile_usage_loop`] 的说明) +/// +/// 两件事: +/// 1. **占用状态复位**:订单已全部结束但 `compartments`/`batteries` 还标着"占用"的,置回空闲 +/// 2. **"无电池"状态兜底**(**待确认第 18 项**):该通道 `batt_status` 的 **bit7 连续 +/// [`energy::NO_BATTERY_STRIKES`] 次为 0**(协议 §5.8 位定义:0=无电池)却还挂着活跃订单 +/// ⇒ 判定"人把电池拔走了但设备没机会上报 `bat_out`",**提前结单**,避免订单一直挂到 10 小时超时。 +/// +/// 为什么放在这里而不是 `status_post` 处理路径:这里只需扫**活跃订单**(数量少), +/// 而 `status_post` 每条要对 36 个通道都判一次"有没有活跃订单"(每柜每分钟 36 次查询,代价大)。 +async fn reconcile_usage_once( + mysql: &MySqlPool, + redis: &ConnectionManager, +) -> Result<(), sqlx::Error> { + reconcile_no_battery_orders(mysql, redis).await?; + + let compartments = sqlx::query( + "UPDATE compartments comp SET comp.status = 0 + WHERE comp.status = 1 + AND EXISTS (SELECT 1 FROM charge_orders co + WHERE co.compartment_id = comp.id AND co.status <> 0) + AND NOT EXISTS (SELECT 1 FROM charge_orders co2 + WHERE co2.compartment_id = comp.id AND co2.status = 0)", + ) + .execute(mysql) + .await?; + + let batteries = sqlx::query( + "UPDATE batteries b SET b.status = 0 + WHERE b.status = 1 + AND EXISTS (SELECT 1 FROM charge_orders co + WHERE co.battery_id = b.battery_id AND co.status <> 0) + AND NOT EXISTS (SELECT 1 FROM charge_orders co2 + WHERE co2.battery_id = b.battery_id AND co2.status = 0)", + ) + .execute(mysql) + .await?; + + if compartments.rows_affected() > 0 || batteries.rows_affected() > 0 { + tracing::info!( + "[reconcile] 占用状态已复位 compartments={} batteries={}", + compartments.rows_affected(), + batteries.rows_affected() + ); + } + Ok(()) +} + +/// **待确认第 18 项的兜底**:对"通道连续多次报无电池但订单还挂着"的订单提前结单。 +/// +/// 场景:停电/离线期间人把电池取走 —— 设备**根本没机会上报 `bat_out`**, +/// 于是订单会一直挂到 10 小时超时。这里用 `status_post` 的 `batt_status` **bit7**(有无电池) +/// 作为兜底信号,配合 [`energy::NO_BATTERY_STRIKES`] 次连续采样过滤瞬时抖动。 +/// +/// ⚠️ **已知局限(待确认第 16 项)**:若某块仓控板整体离线且 4G 层"照报全 0 的板", +/// 这里会把"板离线"误判成"电池被拔"。因此: +/// - 判定阈值取"连续多次"而非一次; +/// - 结单原因记为 `forced`(异常结束)而非 `normal`,人工可从充电记录里识别出这批异常单; +/// - 该分支只对**已有活跃订单**的通道生效(不会凭空影响空仓)。 +async fn reconcile_no_battery_orders( + mysql: &MySqlPool, + redis: &ConnectionManager, +) -> Result<(), sqlx::Error> { + // 只扫活跃订单(数量级远小于全量通道),带出通道定位所需信息 + let active: Vec<(i64, i64, String, i8, i8)> = sqlx::query_as( + "SELECT co.id, co.compartment_id, cab.imei, cb.board_id, comp.channel_id + FROM charge_orders co + JOIN compartments comp ON comp.id = co.compartment_id + JOIN cabin_boards cb ON cb.id = comp.cabin_board_id + JOIN cabinets cab ON cab.id = co.cabinet_id + WHERE co.status = 0 + LIMIT 500", + ) + .fetch_all(mysql) + .await?; + + for (order_id, compartment_id, imei, board_id, channel_id) in active { + let strikes = energy::consecutive_no_battery(redis, &imei, board_id.into(), channel_id.into()).await; + if strikes < energy::NO_BATTERY_STRIKES { + continue; + } + + // 连续无电池 ⇒ 电池已被取走,提前结单(电量取回累加器后再清) + let affected = finalize_order( + mysql, + redis, + &imei, + board_id.into(), + channel_id.into(), + compartment_id, + None, + 2, + "forced", + RECORD_STATUS_ABNORMAL, + ) + .await?; + + if affected > 0 { + mark_compartment_status(mysql, compartment_id, 0).await; + tracing::warn!( + "[reconcile] 通道连续 {} 次无电池,判定电池已被取走并提前结单 \ + order_id={} dev_id={} board={} ch={}(未收到 bat_out 的兜底)", + strikes, + order_id, + imei, + board_id, + channel_id + ); + } + } + + Ok(()) +} + +/// 供 `workers/order_timeout.rs` 调用:**按仓体结算一笔超时订单**(10 小时兜底)。 +/// +/// 存在的意义:超时结单也必须走 [`finalize_order`],否则那条订单的电量永远是 0、 +/// 充电记录也会缺一条(`order_timeout` 模块只有 `compartment_id`, +/// 所以在这里补一次"仓体 → 设备/板/通道"的定位)。 +/// +/// 返回实际结算的订单数(0 = 该仓体已无活跃订单,无需处理)。 +pub async fn finalize_timeout_order( + pool: &MySqlPool, + redis: &ConnectionManager, + compartment_id: i64, +) -> Result { + let location: Option<(String, i8, i8)> = sqlx::query_as( + "SELECT cab.imei, cb.board_id, comp.channel_id + FROM compartments comp + JOIN cabin_boards cb ON cb.id = comp.cabin_board_id + JOIN cabinets cab ON cab.id = cb.cabinet_id + WHERE comp.id = ?", + ) + .bind(compartment_id) + .fetch_optional(pool) + .await?; + + let Some((imei, board_id, channel_id)) = location else { + tracing::warn!( + "[order_timeout] 仓体定位失败,跳过结算 compartment_id={}", + compartment_id + ); + return Ok(0); + }; + + finalize_order( + pool, + redis, + &imei, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + None, + 1, + "timeout", + RECORD_STATUS_ABNORMAL, + ) + .await +} + +/// 按(柜子 ID、仓控板号、通道号)查仓体 ID +async fn find_compartment_id( + pool: &MySqlPool, + cabinet_id: i64, + board_id: i8, + channel_id: i8, +) -> Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT comp.id FROM compartments comp + JOIN cabin_boards cb ON cb.id = comp.cabin_board_id + WHERE cb.cabinet_id = ? AND cb.board_id = ? AND comp.channel_id = ?", + ) + .bind(cabinet_id) + .bind(board_id) + .bind(channel_id) + .fetch_optional(pool) + .await +} + +/// 起始 SOC 兜底:查该柜 IMEI 后读通道 `bat_in` 登记值;拿不到记 0(协议 §5.7:"若拿不到则记 0") +async fn resolve_start_soc( + pool: &MySqlPool, + redis: &ConnectionManager, + cabinet_id: i64, + board_id: i8, + channel_id: i8, +) -> i64 { + let imei: Result, sqlx::Error> = sqlx::query_scalar("SELECT imei FROM cabinets WHERE id = ?") + .bind(cabinet_id) + .fetch_optional(pool) + .await; + + match imei { + Ok(Some(imei)) => match load_channel_battery(redis, &imei, board_id, channel_id).await { + Some(battery) => battery.soc, + None => 0, + }, + Ok(None) => 0, + Err(e) => { + tracing::error!("[order] 查询 IMEI 失败 cabinet_id={} err={}", cabinet_id, e); + 0 + } + } +} + +/// 登记 / 更新电池台账(`status=1 使用中`)。 +/// +/// 新协议的 `brand_code` / `model_code` 是**原值透传**的代码,此处落到台账既有的 +/// `brand` / `model` 列("代码 → 名称"的映射由平台侧维护,协议 §10 第 7 条); +/// `material_code` / `cell_count` / 额定值等新字段随 `device_logs` 全量留痕。 +/// 写入失败只记 ERROR:设备不该因平台台账写失败而重发 `bat_in`。 +async fn upsert_battery_ledger( + mysql: &MySqlPool, + battery_id: &str, + soc: i64, + battery: &serde_json::Value, +) { + let brand_code = battery.get("brand_code").and_then(|v| v.as_str()).unwrap_or(""); + let model_code = battery.get("model_code").and_then(|v| v.as_str()).unwrap_or(""); + let material_code = battery.get("material_code").and_then(|v| v.as_str()).unwrap_or(""); + let cell_count = battery.get("cell_count").and_then(|v| v.as_i64()); + let rated_voltage = battery.get("rated_voltage").and_then(|v| v.as_i64()); + let rated_capacity = battery.get("rated_capacity").and_then(|v| v.as_i64()); + + // 协议 §5.5 的电池属性按【原值】入库:`brand_code` 等是硬件代码, + // 映射出的中文名称留给 `brand` / `model`(待代码表确认后回填)—— + // 依据交付文档 §10 定稿第 7 条:4G 层透传原值、映射在平台侧完成。 + let result: Result = sqlx::query( + "INSERT INTO batteries + (battery_id, soc, brand_code, model_code, material_code, cell_count, + rated_voltage, rated_capacity, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1) + ON DUPLICATE KEY UPDATE soc = VALUES(soc), brand_code = VALUES(brand_code), + model_code = VALUES(model_code), material_code = VALUES(material_code), + cell_count = VALUES(cell_count), rated_voltage = VALUES(rated_voltage), + rated_capacity = VALUES(rated_capacity), status = 1", + ) + .bind(battery_id) + .bind(soc) + .bind(brand_code) + .bind(model_code) + .bind(material_code) + .bind(cell_count) + .bind(rated_voltage) + .bind(rated_capacity) + .execute(mysql) + .await; + + if let Err(e) = result { + tracing::error!("[bat_in] 电池台账写入失败 battery_id={} err={}", battery_id, e); + } +} + +// --------------------------------------------------------------------------- +// bat_in / bat_out +// --------------------------------------------------------------------------- + +/// 处理 `bat_in` —— 电池插入(协议 §5.5):解析 `id` → 登记/更新电池台账 → 记 `device_logs` → 回 `suc:1`。 +/// ⛔ **`bat_in` 与订单无关,这里绝不建单**(订单唯一入口是 `on`,见 §5.7);`battery_id` 为空串时跳过台账。 async fn handle_bat_in( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, + redis: &ConnectionManager, ) -> Result<(), Box> { - tracing::info!("[bat_in] 电池插入 dev_id={}", dev_id); + let (board_id, channel_id, compartment_id, _cabinet_id) = + match resolve_target(msg, dev_id, mysql, redis, "bat_in").await? { + Some(v) => v, + None => return Ok(()), + }; + + let battery = msg.get("battery").filter(|b| b.is_object()); + if battery.is_none() { + tracing::warn!("[bat_in] 缺少 battery 块 dev_id={} id={}", dev_id, msg["id"]); + } + let battery_id = battery + .and_then(|b| b.get("battery_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let info = battery + .and_then(|b| b.get("info")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + // `info` 与 `status_post` 的通道 hex 同格式:byte2 = SOC(1%) → 平台 ‰ + let soc = parse_channel_hex(info).map(|c| c.soc); + if !info.is_empty() && soc.is_none() { + tracing::warn!("[bat_in] info 非 18 位 hex,SOC 按 0 处理 dev_id={} info={}", dev_id, info); + } + + // 台账登记:battery_id 非空才登记(空串 = BMS 读不到序列号,不影响充电) + if battery_id.is_empty() { + tracing::info!("[bat_in] 电池序列号为空,跳过台账登记 dev_id={} board={} ch={}", dev_id, board_id, channel_id); + } else if let Some(b) = battery { + upsert_battery_ledger(mysql, battery_id, soc.unwrap_or(0), b).await; + } + + // 记录通道最近一次 bat_in:`on` 建单时按此取电池 id 与起始 SOC(§5.7) + save_channel_battery( + redis, + dev_id, + board_id, + channel_id, + battery, + battery_id, + soc.unwrap_or(0), + ) + .await; let content = serde_json::json!({ "dev_id": dev_id, - "id": msg["id"], - "timestamp": msg["timestamp"], - "bms": msg.get("bms"), + "act": "bat_in", + "id": msg.get("id"), + "msg_id": msg.get("msg_id"), + "compartment_id": compartment_id, + "board_id": board_id, + "channel_id": channel_id, + "battery_id": battery_id, + "battery": battery, }); + insert_device_log(mysql, dev_id, 2, content).await; - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 2, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + tracing::info!("[bat_in] 已登记 dev_id={} board={} ch={} battery_id={} start_soc={:?}", dev_id, board_id, channel_id, battery_id, soc); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; Ok(()) } -/// 处理电池拔出 +/// 处理 `bat_out` —— 电池拔出(协议 §5.6): +/// 结束该通道活跃订单(`reason='user_stop'`)+ 台账置空闲 + 清理通道电池登记。 async fn handle_bat_out( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, + redis: &ConnectionManager, ) -> Result<(), Box> { - tracing::info!("[bat_out] 电池拔出 dev_id={}", dev_id); + let (board_id, channel_id, compartment_id, _cabinet_id) = + match resolve_target(msg, dev_id, mysql, redis, "bat_out").await? { + Some(v) => v, + None => return Ok(()), + }; + + let battery = msg.get("battery").filter(|b| b.is_object()); + let battery_id = battery + .and_then(|b| b.get("battery_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + // 末帧 SOC:取 `battery.info`(与通道 hex 同格式) + let end_soc = battery + .and_then(|b| b.get("info")) + .and_then(|v| v.as_str()) + .and_then(parse_channel_hex) + .map(|c| c.soc); + + // 结束该通道活跃订单(统一结算:写回平台侧积分电量 + 生成充电记录) + let updated = finalize_order( + mysql, + redis, + dev_id, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + end_soc, + 1, + "user_stop", + RECORD_STATUS_MANUAL_STOP, + ) + .await + .map_err(|e| { + tracing::error!("[bat_out] 结束订单失败 dev_id={} err={}", dev_id, e); + e + })?; + if updated == 0 { + tracing::warn!( + "[bat_out] 无活跃订单可结束 dev_id={} compartment_id={}", + dev_id, + compartment_id + ); + } + + // 订单结束 ⇒ 占用状态复位:仓体空闲(`batteries` 为空串时跳过台账) + mark_compartment_status(mysql, compartment_id, 0).await; + if !battery_id.is_empty() { + mark_battery_status(mysql, battery_id, 0).await; + } + + // 电池已离柜:清掉通道电池登记,避免下一次 `on` 用到已拔出的电池 id + delete_channel_battery(redis, dev_id, board_id, channel_id).await; let content = serde_json::json!({ "dev_id": dev_id, - "id": msg["id"], - "timestamp": msg["timestamp"], + "act": "bat_out", + "id": msg.get("id"), + "msg_id": msg.get("msg_id"), + "compartment_id": compartment_id, + "battery_id": battery_id, + "end_soc": end_soc, }); + insert_device_log(mysql, dev_id, 2, content).await; - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 2, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + tracing::info!( + "[bat_out] 电池拔出 dev_id={} compartment_id={} battery_id={} end_soc={:?}", + dev_id, + compartment_id, + battery_id, + end_soc + ); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; Ok(()) } -/// 处理设备主动上报 off(充满自动停止) +// --------------------------------------------------------------------------- +// 设备上报 on / off(不带 suc 的形态) +// --------------------------------------------------------------------------- + +/// 处理设备上报 `off`(不带 `suc`,充满/故障自动停止)—— 结束活跃订单(`reason='normal'`)。 +/// +/// **该通道没有活跃订单时不处理,但照常回 `suc:1`**(协议 §5.7:老订单可能已被 10 小时超时结束)。 async fn handle_device_off( msg: &serde_json::Value, dev_id: &str, mysql: &MySqlPool, + redis: &ConnectionManager, ) -> Result<(), Box> { - tracing::info!("[off] 设备上报停止充电 dev_id={}", dev_id); + let (board_id, channel_id, compartment_id, _cabinet_id) = + match resolve_target(msg, dev_id, mysql, redis, "off").await? { + Some(v) => v, + None => return Ok(()), + }; + + let end_soc = msg + .get("battery") + .and_then(|b| b.get("info")) + .and_then(|v| v.as_str()) + .and_then(parse_channel_hex) + .map(|c| c.soc); + + // 结束订单(统一结算:写回平台侧积分电量 + 生成充电记录) + let updated = finalize_order( + mysql, + redis, + dev_id, + i64::from(board_id), + i64::from(channel_id), + compartment_id, + end_soc, + 1, + "normal", + RECORD_STATUS_NORMAL, + ) + .await + .map_err(|e| { + tracing::error!("[off] 结束订单失败 dev_id={} err={}", dev_id, e); + e + })?; + if updated == 0 { + tracing::info!( + "[off] 无活跃订单,不做处理 dev_id={} compartment_id={}", + dev_id, + compartment_id + ); + } else { + // 订单结束 ⇒ 占用状态复位:仓体不再"充电中",电池台账置空闲 + // (`off` 后电池仍可能留在柜内,但已不在使用中;残留由 reconcile 对账兜底) + mark_compartment_status(mysql, compartment_id, 0).await; + let battery_id = msg + .get("battery") + .and_then(|b| b.get("battery_id")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if !battery_id.is_empty() { + mark_battery_status(mysql, battery_id, 0).await; + } + } let content = serde_json::json!({ "dev_id": dev_id, - "id": msg["id"], - "msg_id": msg["msg_id"], - "timestamp": msg["timestamp"], + "act": "off", + "id": msg.get("id"), + "msg_id": msg.get("msg_id"), + "compartment_id": compartment_id, + "end_soc": end_soc, }); + insert_device_log(mysql, dev_id, 2, content).await; - sqlx::query( - "INSERT INTO device_logs (cabinet_id, log_type, content) VALUES ( - (SELECT id FROM cabinets WHERE imei = ?), 2, ? - )", - ) - .bind(dev_id) - .bind(content.to_string()) - .execute(mysql) - .await?; + tracing::info!( + "[off] 充电停止 dev_id={} compartment_id={} end_soc={:?}", + dev_id, + compartment_id, + end_soc + ); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; Ok(()) } + +/// 处理设备上报 `on`(不带 `suc`,自动充电模式下设备自行开始充电)—— **这是建单入口之一**。 +/// +/// 起始 SOC 与电池 id 取该通道最近一次 `bat_in` 的登记(`on` 报文本身不带 `info`,协议 §5.7)。 +/// 建单失败只记 ERROR 仍回 `suc:1`:设备**已经实际开始充电**,让设备重发不会改变事实, +/// 订单登记属平台内部账务问题(协议 §3.6:超时 ≠ 未执行)。 +async fn handle_device_on( + msg: &serde_json::Value, + dev_id: &str, + mysql: &MySqlPool, + redis: &ConnectionManager, +) -> Result<(), Box> { + let (board_id, channel_id, _compartment_id, cabinet_id) = + match resolve_target(msg, dev_id, mysql, redis, "on").await? { + Some(v) => v, + None => return Ok(()), + }; + + // 电池 id / 起始 SOC:回溯该通道最近一次 bat_in(拿不到则按未知电池建单) + let (battery_id, start_soc) = match load_channel_battery(redis, dev_id, board_id, channel_id).await { + Some(battery) => (battery.battery_id, Some(battery.soc)), + None => { + tracing::warn!( + "[on] 该通道无 bat_in 登记,按未知电池建单 dev_id={} board={} ch={}", + dev_id, + board_id, + channel_id + ); + (String::new(), None) + } + }; + + if let Err(e) = ensure_charge_order( + mysql, + redis, + dev_id, + cabinet_id, + board_id, + channel_id, + &battery_id, + start_soc, + ) + .await + { + tracing::error!( + "[on] 建单失败 dev_id={} board={} ch={} err={}", + dev_id, + board_id, + channel_id, + e + ); + } + + let content = serde_json::json!({ + "dev_id": dev_id, + "act": "on", + "id": msg.get("id"), + "msg_id": msg.get("msg_id"), + "battery_id": battery_id, + "start_soc": start_soc, + }); + insert_device_log(mysql, dev_id, 2, content).await; + + tracing::info!( + "[on] 设备上报开始充电 dev_id={} board={} ch={} battery_id={}", + dev_id, + board_id, + channel_id, + battery_id + ); + + let resp = ServerResponse::success_for(msg); + send_response_to_device(redis, dev_id, &resp).await?; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// 指令回执(带 suc 的上行) +// --------------------------------------------------------------------------- + +/// 处理指令执行回执(`open` / `pow_on` / `pow_off` / `reset` / 回执形态的 `on` / `off`)。 +/// +/// 协议 §3.5:**回执是上行应答,平台一律不再回包**(双方都对"应答"回包会形成回环)。 +/// - **A4 配对**:device-server 下发时写挂起项 `pending:{dev_id}` → field = `msg_id`、value = `act`。 +/// 这里先 `HGET` 取挂起项的 `act`,与回执的 `act` **双条件命中**才 `HDEL` +/// (协议 §3.5:配对必须同时校验 `msg_id` 与 `act`;仅凭 `msg_id` 可能错配另一个交互)。 +/// 字段不存在 → 记 WARN「迟到的回执,已作废」,仍然不回包。 +/// - **60 秒语义**:挂起项没有入队时间,且 device-server 已完工(挂起项 TTL 120 秒兜底自动清理), +/// 因此这里**只按"字段是否存在"判定**:命中即视为 60 秒内到达;字段不存在即视为已作废。 +/// 120 秒内到达的"真迟到"回执会被记为已配对——回执本就只用于记录与配对,不触发任何业务动作, +/// 所以不影响正确性(这也正是协议 §3.6「超时后不重做、不补记业务动作」的要求)。 +/// - **A6 记录**:设备回执里的 `err`(`suc=0` 时的失败原因)写入 `device_logs.content`。 +async fn handle_cmd_result( + msg: &serde_json::Value, + dev_id: &str, + mysql: &MySqlPool, + redis: &ConnectionManager, +) -> Result<(), Box> { + let act = msg["act"].as_str().unwrap_or("unknown"); + let suc = msg["suc"].as_i64().unwrap_or(0); + let id = msg["id"].as_str().unwrap_or(""); + let err = msg.get("err").and_then(|v| v.as_str()).unwrap_or(""); + let msg_id = msg.get("msg_id").and_then(|v| v.as_u64()); + + match msg_id { + Some(msg_id) => pair_pending_command(redis, dev_id, act, msg_id).await, + None => tracing::warn!("[cmd_result] 回执缺少 msg_id,无法配对 dev_id={} act={}", dev_id, act), + } + + // A6:`suc=0` 的设备侧原因必须留痕(协议只要求 err 为英文短语,不限制取值) + if suc == 0 { + tracing::warn!( + "[cmd_result] 设备执行失败 dev_id={} act={} id={} err={}", + dev_id, + act, + id, + err + ); + } else { + tracing::info!("[cmd_result] dev_id={} act={} suc=1 id={}", dev_id, act, id); + } + + let content = serde_json::json!({ + "dev_id": dev_id, + "act": act, + "suc": suc, + "err": err, + "id": id, + "msg_id": msg_id, + "timestamp": msg["timestamp"], + }); + insert_device_log(mysql, dev_id, 2, content).await; + + // 协议 §3.5:收到回执【不回包】—— 这里刻意什么都不发 + Ok(()) +} + +/// A4 配对:拿回执的 `(msg_id, act)` 去挂起表 `pending:{dev_id}` 命中并删除。 +/// +/// 挂起表由 device-server 在**下发时**写入:field = `msg_id`(十进制字符串)、 +/// value = `act`、整个 hash TTL 120 秒(超时自动清理,平台侧不重传、协议 §3.6)。 +async fn pair_pending_command(redis: &ConnectionManager, dev_id: &str, act: &str, msg_id: u64) { + let key = pending_key(dev_id); + let mut conn = redis.clone(); + + // 先查 value(下发的 act),实现 §3.5 要求的 `msg_id` + `act` 双条件配对 + let pending_act: Result, redis::RedisError> = redis::cmd("HGET") + .arg(&key) + .arg(msg_id) + .query_async(&mut conn) + .await; + + let pending_act = match pending_act { + Ok(v) => v, + Err(e) => { + tracing::error!("[cmd_result] 读取挂起项失败 key={} msg_id={} err={}", key, msg_id, e); + return; + } + }; + + let matched = match pending_act.as_deref() { + None => false, + Some(pending) => pending == act, + }; + + if !matched { + match pending_act.as_deref() { + // 字段不存在:已超时作废(或无对应下发)→ 不改动挂起表,留给真正的回执 + None => tracing::warn!( + "[cmd_result] 迟到的回执,已作废 dev_id={} act={} msg_id={}", + dev_id, + act, + msg_id + ), + Some(pending) => tracing::warn!( + "[cmd_result] 回执 act 与挂起项不一致,忽略 dev_id={} recv_act={} pending_act={} msg_id={}", + dev_id, + act, + pending, + msg_id + ), + } + return; + } + + let deleted: Result = redis::cmd("HDEL") + .arg(&key) + .arg(msg_id) + .query_async(&mut conn) + .await; + match deleted { + Ok(_) => tracing::debug!( + "[cmd_result] 回执已配对 dev_id={} act={} msg_id={}", + dev_id, + act, + msg_id + ), + Err(e) => tracing::error!( + "[cmd_result] 删除挂起项失败 key={} msg_id={} err={}", + key, + msg_id, + e + ), + } +} + +// --------------------------------------------------------------------------- +// 单元测试:协议解析与跨模块契约 +// +// 覆盖本轮新增的**纯函数**(协议 §3.7 / §3.8 / §5.3 的解析规则)。 +// 依赖 DB/Redis 的业务逻辑(订单、台账、挂起配对)不在此处测 —— 属集成测试范畴。 +// --------------------------------------------------------------------------- +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// 按协议 §5.3 的字节布局构造 ChannelStatus,便于断言语义正确 + fn cs(batt: &str, pow: &str, soc: i64, voltage: i64, current: i64, temp: i64) -> ChannelStatus { + ChannelStatus { + batt_status: batt.to_string(), + pow_status: pow.to_string(), + soc, + voltage, + current, + temp, + } + } + + // ---- parse_channel_hex:18 字符 hex(9 字节,大端)---------------------- + + /// 交付文档 §5.3 的示例:有电池充电中 + 电源打开 + #[test] + fn test_parse_channel_hex_doc_example_full() { + assert_eq!( + parse_channel_hex("828255152c01f40143"), + Some(cs("82", "82", 850, 54200, 5000, 32300)) + ); + } + + /// 交付文档 §5.5 `bat_in` 的 info 示例:有电池未充电 + #[test] + fn test_parse_channel_hex_doc_example_bat_in() { + assert_eq!( + parse_channel_hex("80825515180320012a"), + Some(cs("80", "82", 850, 54000, 8000, 29800)) + ); + } + + /// 交付文档 §5.6 `bat_out` 末帧示例 + #[test] + fn test_parse_channel_hex_doc_example_bat_out() { + assert_eq!( + parse_channel_hex("80005f15180000012a"), + Some(cs("80", "00", 950, 54000, 0, 29800)) + ); + } + + /// 交付文档 §5.7 `off` 末帧示例:充满 + #[test] + fn test_parse_channel_hex_doc_example_off() { + assert_eq!( + parse_channel_hex("81026415540000012a"), + Some(cs("81", "02", 1000, 54600, 0, 29800)) + ); + } + + /// **空仓**:byte0 = 00(无电池),但 byte1 照实报电源状态 —— 两个维度相互独立 + /// (这是协议定稿的明确要求,不能因为"没电池"就把电源写成 0) + #[test] + fn test_parse_channel_hex_empty_channel_keeps_power_status() { + assert_eq!( + parse_channel_hex("008200000000000000"), + Some(cs("00", "82", 0, 0, 0, 0)) + ); + } + + /// hex 大小写不敏感,且状态字节统一输出小写(与 §3.8 示例一致) + #[test] + fn test_parse_channel_hex_case_insensitive_and_lowercased() { + assert_eq!( + parse_channel_hex("828255152C01F40143"), + Some(cs("82", "82", 850, 54200, 5000, 32300)) + ); + } + + /// 长度必须恰好 18(9 字节),多/少一个字符都拒绝 + #[test] + fn test_parse_channel_hex_rejects_wrong_length() { + assert_eq!(parse_channel_hex(""), None); + assert_eq!(parse_channel_hex("828255152c01f4014"), None); // 17 + assert_eq!(parse_channel_hex("828255152c01f401433"), None); // 19 + } + + /// 非 hex 字符拒绝(且不能 panic) + #[test] + fn test_parse_channel_hex_rejects_non_hex() { + assert_eq!(parse_channel_hex("zz8255152c01f40143"), None); + assert_eq!(parse_channel_hex("82 255152c01f40143"), None); + } + + /// 多字节 UTF-8(按字节切分会落到字符中间)必须安全返回 None,不得 panic + #[test] + fn test_parse_channel_hex_handles_multibyte_without_panic() { + // 9 个 2 字节汉字 = 18 字节,长度校验能过,但按字符边界取子串会失败 + assert_eq!(parse_channel_hex("状态状态状态状态状态状态状态状态状态"), None); + } + + // ---- normalize_hex_byte:柜体状态 2 位 hex(§3.8)--------------------- + + #[test] + fn test_normalize_hex_byte_valid_and_lowercased() { + assert_eq!(normalize_hex_byte(Some("82")), Some("82".to_string())); + assert_eq!(normalize_hex_byte(Some("8A")), Some("8a".to_string())); + assert_eq!(normalize_hex_byte(Some("00")), Some("00".to_string())); + } + + #[test] + fn test_normalize_hex_byte_rejects_invalid() { + assert_eq!(normalize_hex_byte(None), None); + assert_eq!(normalize_hex_byte(Some("")), None); + assert_eq!(normalize_hex_byte(Some("8")), None); + assert_eq!(normalize_hex_byte(Some("882")), None); + assert_eq!(normalize_hex_byte(Some("ZZ")), None); + } + + // ---- parse_compartment_id:`仓控板-通道` 两段(§3.7)------------------- + + #[test] + fn test_parse_compartment_id_accepts_two_segments() { + assert_eq!(parse_compartment_id(&json!({"id": "01-3"})), Some((1, 3))); + assert_eq!(parse_compartment_id(&json!({"id": "1-3"})), Some((1, 3))); + // 通道补零也兼容 + assert_eq!(parse_compartment_id(&json!({"id": "01-03"})), Some((1, 3))); + // 量程上界:仓控板 15、通道 8 + assert_eq!(parse_compartment_id(&json!({"id": "15-8"})), Some((15, 8))); + } + + /// **段数必须恰好为 2**:柜级(0 段)与旧的含 IMEI 三段写法都必须拒绝 + #[test] + fn test_parse_compartment_id_rejects_other_segment_counts() { + assert_eq!(parse_compartment_id(&json!({"id": "1"})), None); + assert_eq!(parse_compartment_id(&json!({"id": "864720562557021"})), None); + // 旧协议写法(`IMEI-仓控板-通道`)现在是非法的,必须报 invalid id + assert_eq!(parse_compartment_id(&json!({"id": "864720562557021-01-3"})), None); + } + + #[test] + fn test_parse_compartment_id_rejects_invalid_values() { + assert_eq!(parse_compartment_id(&json!({})), None); // 缺 id + assert_eq!(parse_compartment_id(&json!({"id": 3})), None); // 非字符串 + assert_eq!(parse_compartment_id(&json!({"id": "0-3"})), None); // 仓控板 < 1 + assert_eq!(parse_compartment_id(&json!({"id": "01-0"})), None); // 通道 < 1 + assert_eq!(parse_compartment_id(&json!({"id": "a-3"})), None); // 非数字 + assert_eq!(parse_compartment_id(&json!({"id": "-3"})), None); // 空首段 + } + + // ---- Redis key 契约(跨模块:device-server 写入 / cabinets.rs 读取)----- + + /// 通道实时状态键:板号/通道号**十进制不补零**,与 `routes/cabinets.rs` 读取端一致 + #[test] + fn test_channel_status_key_format_contract() { + assert_eq!( + channel_status_key("864720562557021", 1, 3), + "device:864720562557021:board:1:ch:3" + ); + assert_eq!( + channel_status_key("864720562557021", 15, 8), + "device:864720562557021:board:15:ch:8" + ); + } + + /// 电池信息内部键(`bat_in` 写入、建单回溯 SOC 读取) + #[test] + fn test_channel_battery_key_format_contract() { + assert_eq!( + channel_battery_key("864720562557021", 1, 3), + "device:864720562557021:board:1:ch:3:bat" + ); + } + + /// 挂起项键必须与 device-server 的 `HSET pending:{dev_id}` 完全一致(否则回执配不上对) + #[test] + fn test_pending_key_format_matches_device_server() { + assert_eq!(pending_key("864720562557021"), "pending:864720562557021"); + } + + // ---- 停电标记:cabinet.status1 bit0(§3.8 / §5.4)--------------------- + + /// bit0 = 0(供电正常)→ 清除停电标记;bit0 = 1(仍在报警)→ 保留标记。 + /// 这里只校验判定所用的掩码语义(真正的 HSET 在集成测试覆盖) + #[test] + fn test_power_fail_mask_semantics() { + assert_eq!(CABINET_STATUS1_POWER_FAIL_MASK, 0x01); + // 0x00 / 0x10 / 0x82 的 bit0 均为 0 → 视为供电正常 + for byte in [0x00u8, 0x10, 0x82, 0xfe] { + assert_eq!(byte & CABINET_STATUS1_POWER_FAIL_MASK, 0, "byte={byte:#04x}"); + } + // bit0 = 1 → 仍在断电报警 + for byte in [0x01u8, 0x11, 0x83, 0xff] { + assert_ne!(byte & CABINET_STATUS1_POWER_FAIL_MASK, 0, "byte={byte:#04x}"); + } + } + + /// 通道 hex 长度常量必须与协议 `cell_count × 4 + 16` 无关的固定 18 一致 + #[test] + fn test_channel_hex_len_constant() { + assert_eq!(CHANNEL_HEX_LEN, 18); + assert_eq!(COMPARTMENT_ID_SEGMENTS, 2); + } + + // ---- 建单判定(协议 §5.5 / §5.7 的核心业务规则)--------------------- + + /// 无活跃订单 → 新建(正常路径:上一轮已由 bat_out/off 结束) + #[test] + fn test_decide_order_action_create_when_no_active_order() { + assert_eq!(decide_order_action(None, "SN123456"), OrderDecision::Create); + // 电池 id 为空也照常新建(BMS 读不到序列号不影响充电) + assert_eq!(decide_order_action(None, ""), OrderDecision::Create); + } + + /// 同一块电池重复上报 `on` → 不新建(设备重发同一次充电) + #[test] + fn test_decide_order_action_skip_same_battery() { + assert_eq!( + decide_order_action(Some("SN123456"), "SN123456"), + OrderDecision::Skip + ); + } + + /// **换电池** → 老订单必须先结束(依据:一个通道只可能有一块电池) + #[test] + fn test_decide_order_action_replace_on_different_battery() { + assert_eq!( + decide_order_action(Some("SN_OLD"), "SN_NEW"), + OrderDecision::Replace + ); + } + + /// **任一方 id 为空串 → 视为未变**(无法证明换了电池时保守处理,避免重复建单) + #[test] + fn test_decide_order_action_skip_when_either_battery_id_empty() { + assert_eq!(decide_order_action(Some(""), "SN_NEW"), OrderDecision::Skip); + assert_eq!(decide_order_action(Some("SN_OLD"), ""), OrderDecision::Skip); + assert_eq!(decide_order_action(Some(""), ""), OrderDecision::Skip); + } + + /// 仅"双方都已知且不同"才判为换电池 —— 这条边界决定会不会多建订单 + #[test] + fn test_decide_order_action_boundary_is_strict() { + let cases = [ + (None, "", OrderDecision::Create), + (Some("A"), "A", OrderDecision::Skip), + (Some("A"), "B", OrderDecision::Replace), + (Some("A"), "", OrderDecision::Skip), + (Some(""), "B", OrderDecision::Skip), + ]; + for (active, new, expect) in cases { + assert_eq!( + decide_order_action(active, new), + expect, + "active={active:?} new={new:?}" + ); + } + } +} diff --git a/software/device-server/.env.example b/software/device-server/.env.example new file mode 100644 index 0000000..0d60022 --- /dev/null +++ b/software/device-server/.env.example @@ -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 \ No newline at end of file diff --git a/software/device-server/.env.production b/software/device-server/.env.production deleted file mode 100644 index dbd5a3c..0000000 --- a/software/device-server/.env.production +++ /dev/null @@ -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 diff --git a/software/device-server/.env.test b/software/device-server/.env.test deleted file mode 100644 index 681ebc6..0000000 --- a/software/device-server/.env.test +++ /dev/null @@ -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 diff --git a/software/device-server/Cargo.lock b/software/device-server/Cargo.lock index 39281a1..9f3ac83 100644 --- a/software/device-server/Cargo.lock +++ b/software/device-server/Cargo.lock @@ -540,6 +540,7 @@ dependencies = [ "redis", "serde", "serde_json", + "socket2 0.5.10", "thiserror", "tokio", "tokio-util", diff --git a/software/device-server/Cargo.toml b/software/device-server/Cargo.toml index 0298b1a..e9daf11 100644 --- a/software/device-server/Cargo.toml +++ b/software/device-server/Cargo.toml @@ -16,3 +16,4 @@ bytes = "1" futures = "0.3" dotenvy = "0.15" chrono = { version = "0.4", features = ["serde"] } +socket2 = "0.5" diff --git a/software/device-server/src/config.rs b/software/device-server/src/config.rs index 2a2459a..82b2652 100644 --- a/software/device-server/src/config.rs +++ b/software/device-server/src/config.rs @@ -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"), } } } diff --git a/software/device-server/src/main.rs b/software/device-server/src/main.rs index c5ecd9a..5d17224 100644 --- a/software/device-server/src/main.rs +++ b/software/device-server/src/main.rs @@ -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, redis::RedisError> = match redis::cmd("RPOP") + .arg(&my_queue) + .query_async::>(&mut conn) + .await + { + Ok(Some(v)) => Ok(Some(v)), + // 本节点队列为空时再取统一队列(多节点场景下的广播队列) + Ok(None) => { + let mut conn2 = redis.clone(); + redis::cmd("RPOP") + .arg("device:commands") + .query_async::>(&mut conn2) + .await + } + Err(e) => Err(e), + }; - match result { - Ok((_key, cmd_json)) => { - // 从 JSON 中提取 dev_id 用于路由 - let dev_id = serde_json::from_str::(&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::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 = 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; } } diff --git a/software/device-server/src/tcp/connection.rs b/software/device-server/src/tcp/connection.rs index 02d1c09..8e292cb 100644 --- a/software/device-server/src/tcp/connection.rs +++ b/software/device-server/src/tcp/connection.rs @@ -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, - /// 连接建立时间。 - pub connected_at: Instant, + pub tx: mpsc::Sender, /// 最后活跃时间(收到任意消息时更新)。 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) -> Self { + /// 创建连接状态。设备身份不在结构体内重复保存 —— 它已是连接池 map 的 key。 + pub fn new(tx: mpsc::Sender) -> 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>>, + /// 挂起登记用的 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, &'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 { - 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); } } diff --git a/software/device-server/src/tcp/handler.rs b/software/device-server/src/tcp/handler.rs index 536e4e5..a232304 100644 --- a/software/device-server/src/tcp/handler.rs +++ b/software/device-server/src/tcp/handler.rs @@ -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` 但恒为 `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 { +) { + 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) diff --git a/software/device-server/src/tcp/protocol.rs b/software/device-server/src/tcp/protocol.rs index 9e441a1..37d2632 100644 --- a/software/device-server/src/tcp/protocol.rs +++ b/software/device-server/src/tcp/protocol.rs @@ -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, pub msg_id: Option, /// 登录签名时的时间戳(秒级) @@ -22,63 +28,120 @@ pub struct DeviceMessage { pub sign: Option, /// 485 子设备 ID(仓控板 ID 1-6) pub sub_device_id: Option, - /// 状态上报时的 HEX 数据 - pub status: Option, /// 4G 信号强度 pub rssi: Option, + /// `status_post` 的**柜体块**:`{status1, status2, temp, meter}`(见交付文档 §5.3) + #[serde(skip_serializing_if = "Option::is_none")] + pub cabinet: Option, + /// `status_post` 的**仓控板数组**:`[{board, channels: ["<18hex>", ...]}]`(见交付文档 §5.3)。 + /// + /// 通道数据在设备侧已是 **18 字符 hex 明文**,本服务**只透传、不拆解位域**。 + #[serde(skip_serializing_if = "Option::is_none")] + pub boards: Option, + /// `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, /// 未在顶层定义的扩展字段 #[serde(flatten)] pub extra: Option, } -/// 平台回复设备的消息。 -#[derive(Debug, Serialize)] -pub struct ServerResponse { - /// 1=成功,0=失败 - pub suc: u8, - pub msg_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub err: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub auth_str: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub data: Option, -} +// ───────────────────────────────────────────────────────────────────────────── +// 关于「平台回复设备」与「平台下发指令」两类报文: +// +// 本服务**只做透传** —— 回复走 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) -> 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, err: impl Into) -> 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, - #[serde(skip_serializing_if = "Option::is_none")] - pub sub_device_id: Option, - /// 扩展参数(开门指令、充电参数等) - #[serde(flatten)] - pub params: Option, -} - -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()); + } } } diff --git a/software/device-server/src/tcp/server.rs b/software/device-server/src/tcp/server.rs index e2a1747..fc4b9e4 100644 --- a/software/device-server/src/tcp/server.rs +++ b/software/device-server/src/tcp/server.rs @@ -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::(); + // 写通道:HTTP 命令下发通过此通道写入 TCP,缓冲区 100 条防堆积 + let (tx, mut rx) = mpsc::channel::(100); let mut current_dev_id: Option = 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::(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); + } +} diff --git a/software/h5-taro/babel.config.js b/software/h5-taro/babel.config.js deleted file mode 100644 index a49260e..0000000 --- a/software/h5-taro/babel.config.js +++ /dev/null @@ -1,9 +0,0 @@ -// babel-preset-taro 更多配置和用法:https://docs.taro.zone/docs/next/babel-config -module.exports = { - presets: [ - ['taro', { - framework: 'react', - ts: true, - }], - ], -} diff --git a/software/h5-taro/config/dev.js b/software/h5-taro/config/dev.js deleted file mode 100644 index 73170a7..0000000 --- a/software/h5-taro/config/dev.js +++ /dev/null @@ -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': '' }, - }, - }, - }, - }, -} diff --git a/software/h5-taro/config/index.js b/software/h5-taro/config/index.js deleted file mode 100644 index 19a301e..0000000 --- a/software/h5-taro/config/index.js +++ /dev/null @@ -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')) -} diff --git a/software/h5-taro/config/prod.js b/software/h5-taro/config/prod.js deleted file mode 100644 index c7746a5..0000000 --- a/software/h5-taro/config/prod.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - env: { - NODE_ENV: '"production"', - }, - outputRoot: 'dist', -} diff --git a/software/h5-taro/package.json b/software/h5-taro/package.json deleted file mode 100644 index 7a8b688..0000000 --- a/software/h5-taro/package.json +++ /dev/null @@ -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" - } -} diff --git a/software/h5-taro/project.config.json b/software/h5-taro/project.config.json deleted file mode 100644 index 3eaac50..0000000 --- a/software/h5-taro/project.config.json +++ /dev/null @@ -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" -} diff --git a/software/h5-taro/src/api/index.ts b/software/h5-taro/src/api/index.ts deleted file mode 100644 index c3d91e7..0000000 --- a/software/h5-taro/src/api/index.ts +++ /dev/null @@ -1,161 +0,0 @@ -import Taro from '@tarojs/taro' - -const BASE_URL = '/h5/api' - -async function request( - path: string, - options: { method?: string; body?: unknown; params?: Record } = {}, -): Promise { - const token = Taro.getStorageSync('h5_token') - const headers: Record = { - '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 }), - - // 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 } }), -} diff --git a/software/h5-taro/src/app.config.ts b/software/h5-taro/src/app.config.ts deleted file mode 100644 index bfeda26..0000000 --- a/software/h5-taro/src/app.config.ts +++ /dev/null @@ -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', - }, - ], - }, -} diff --git a/software/h5-taro/src/app.css b/software/h5-taro/src/app.css deleted file mode 100644 index cad1512..0000000 --- a/software/h5-taro/src/app.css +++ /dev/null @@ -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; -} diff --git a/software/h5-taro/src/app.tsx b/software/h5-taro/src/app.tsx deleted file mode 100644 index ddcd522..0000000 --- a/software/h5-taro/src/app.tsx +++ /dev/null @@ -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 diff --git a/software/h5-taro/src/assets/device-active.svg b/software/h5-taro/src/assets/device-active.svg deleted file mode 100644 index 24af383..0000000 --- a/software/h5-taro/src/assets/device-active.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/device.svg b/software/h5-taro/src/assets/device.svg deleted file mode 100644 index b4989b3..0000000 --- a/software/h5-taro/src/assets/device.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/home-active.svg b/software/h5-taro/src/assets/home-active.svg deleted file mode 100644 index 6fcc4b7..0000000 --- a/software/h5-taro/src/assets/home-active.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/home.svg b/software/h5-taro/src/assets/home.svg deleted file mode 100644 index 1c33fc6..0000000 --- a/software/h5-taro/src/assets/home.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/me-active.svg b/software/h5-taro/src/assets/me-active.svg deleted file mode 100644 index fc62f1f..0000000 --- a/software/h5-taro/src/assets/me-active.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/assets/me.svg b/software/h5-taro/src/assets/me.svg deleted file mode 100644 index d73cfb6..0000000 --- a/software/h5-taro/src/assets/me.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/software/h5-taro/src/components/ChannelCard.tsx b/software/h5-taro/src/components/ChannelCard.tsx deleted file mode 100644 index 0ae802e..0000000 --- a/software/h5-taro/src/components/ChannelCard.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react' -import { View, Text } from '@tarojs/components' -import { ChannelStatusBadge } from './StatusBadge' - -interface Props { - channelId: number - status: number - voltage?: number - current?: number - onClick?: () => void -} - -const COLORS: Record = { - 0: '#9ca3af', // idle - 1: '#16a34a', // charging - 2: '#dc2626', // fault - 3: '#d1d5db', // offline -} - -export default function ChannelCard({ channelId, status, voltage, current, onClick }: Props) { - return ( - - - #{channelId} - - - {voltage !== undefined && ( - {voltage.toFixed(1)}V - )} - {current !== undefined && ( - {current.toFixed(1)}A - )} - - ) -} diff --git a/software/h5-taro/src/components/ConfirmDialog.tsx b/software/h5-taro/src/components/ConfirmDialog.tsx deleted file mode 100644 index 402796b..0000000 --- a/software/h5-taro/src/components/ConfirmDialog.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react' -import { View, Text } from '@tarojs/components' - -interface Props { - open: boolean - title: string - message: string - onConfirm: () => void - onCancel: () => void -} - -export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: Props) { - if (!open) return null - return ( - - - - {title} - {message} - - - - 取消 - - - 确定 - - - - - ) -} diff --git a/software/h5-taro/src/components/ErrorState.tsx b/software/h5-taro/src/components/ErrorState.tsx deleted file mode 100644 index f29c927..0000000 --- a/software/h5-taro/src/components/ErrorState.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react' -import { View, Text, Button } from '@tarojs/components' - -interface Props { - message: string - onRetry?: () => void -} - -export default function ErrorState({ message, onRetry }: Props) { - return ( - - 😵 - {message} - {onRetry && ( - - )} - - ) -} diff --git a/software/h5-taro/src/components/Loading.tsx b/software/h5-taro/src/components/Loading.tsx deleted file mode 100644 index a88a213..0000000 --- a/software/h5-taro/src/components/Loading.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react' -import { View, Text } from '@tarojs/components' - -export default function Loading({ text = '加载中...' }: { text?: string }) { - return ( - - - {text} - - - ) -} diff --git a/software/h5-taro/src/components/StatusBadge.tsx b/software/h5-taro/src/components/StatusBadge.tsx deleted file mode 100644 index 47c467c..0000000 --- a/software/h5-taro/src/components/StatusBadge.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react' -import { View, Text } from '@tarojs/components' - -const STATUS_MAP: Record = { - 0: { label: '空闲', cls: 'idle' }, - 1: { label: '充电中', cls: 'charging' }, - 2: { label: '故障', cls: 'fault' }, - 3: { label: '离线', cls: 'offline' }, -} - -export function CabinetStatusBadge({ status }: { status: number }) { - const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' } - return ( - - {s.label} - - ) -} - -export function ChannelStatusBadge({ status }: { status: number }) { - const s = STATUS_MAP[status] || { label: '未知', cls: 'offline' } - return ( - - {s.label} - - ) -} diff --git a/software/h5-taro/src/index.html b/software/h5-taro/src/index.html deleted file mode 100644 index 02751dd..0000000 --- a/software/h5-taro/src/index.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - 安知充 - - - -
- - diff --git a/software/h5-taro/src/pages/compartment-detail/index.config.ts b/software/h5-taro/src/pages/compartment-detail/index.config.ts deleted file mode 100644 index 990f839..0000000 --- a/software/h5-taro/src/pages/compartment-detail/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '通道详情', -} diff --git a/software/h5-taro/src/pages/compartment-detail/index.tsx b/software/h5-taro/src/pages/compartment-detail/index.tsx deleted file mode 100644 index c6683dc..0000000 --- a/software/h5-taro/src/pages/compartment-detail/index.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro, { useRouter } from '@tarojs/taro' -import { h5Api, type CompartmentDetail } from '../../api' -import Loading from '../../components/Loading' -import ErrorState from '../../components/ErrorState' -import ConfirmDialog from '../../components/ConfirmDialog' -import { ChannelStatusBadge } from '../../components/StatusBadge' - -export default function CompartmentDetailPage() { - const { params } = useRouter() - const id = params.id - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - const [actionLoading, setActionLoading] = useState('') - const [confirmAction, setConfirmAction] = useState<'stop' | 'door' | null>(null) - - // 隐藏 tabbar(非 tab 页) - useEffect(() => { - Taro.hideTabBar() - }, []) - - const fetchDetail = async () => { - if (!id) return - setLoading(true) - setError('') - try { - const res = await h5Api.getCompartmentDetail(Number(id)) - setData(res.data) - } catch { - setError('加载通道详情失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchDetail() - // ponytail: 15s auto-refresh during charging, add WebSocket for real-time - const timer = setInterval(fetchDetail, 15000) - return () => clearInterval(timer) - }, [id]) - - const handleAction = async (action: 'start' | 'stop' | 'door') => { - if (!id || actionLoading) return - setActionLoading(action) - try { - if (action === 'start') { - await h5Api.startCharge(Number(id)) - } else if (action === 'stop') { - await h5Api.stopCharge(Number(id)) - } else if (action === 'door') { - await h5Api.openDoor(Number(id)) - } - setConfirmAction(null) - fetchDetail() - } catch { - Taro.showToast({ title: '操作失败', icon: 'none' }) - } finally { - setActionLoading('') - } - } - - if (loading) return - if (error && !data) return - if (!data) return null - - const isCharging = data.status === 1 - const isIdle = data.status === 0 - - return ( - - {/* Real-time status */} - - - 实时状态 - - - - - {data.soc ?? '-'}% - SOC - - - {data.soh ?? '-'}% - SOH - - - {data.energy?.toFixed(1) ?? '-'} - 能耗(kWh) - - - - - {/* Electrical params */} - - 电气参数 - - - 电压 - {data.voltage?.toFixed(1) ?? '-'} V - - - 电流 - {data.current?.toFixed(1) ?? '-'} A - - - 功率 - {data.power?.toFixed(1) ?? '-'} W - - - 能耗 - {data.energy?.toFixed(3) ?? '-'} kWh - - - - - {/* BMS data */} - {data.cell_voltages && data.cell_voltages.length > 0 && ( - - BMS 数据 - - 单体电压 (V) - - {data.cell_voltages.map((v, i) => ( - - #{i + 1}: {v.toFixed(3)} - - ))} - - - {data.cell_temps && data.cell_temps.length > 0 && ( - - 温度 (°C) - - {data.cell_temps.map((t, i) => ( - 50 ? '#fef2f2' : '#fff7ed', - padding: '4px 12px', borderRadius: 8, fontSize: 24, - color: t > 50 ? '#dc2626' : '#f97316', - }} - > - #{i + 1}: {t.toFixed(1)} - - ))} - - - )} - {data.bms_alerts && data.bms_alerts.length > 0 && ( - - 告警 - {data.bms_alerts.map((alert, i) => ( - • {alert} - ))} - - )} - - )} - - {/* Charge records */} - {data.charge_records && data.charge_records.length > 0 && ( - - 充电记录 - {data.charge_records.slice(0, 5).map((record) => ( - - - {record.start_time.slice(0, 16)} - {record.end_time && {record.end_time.slice(0, 16)}} - - - {record.energy_kwh.toFixed(2)} kWh - ¥{record.cost.toFixed(2)} - - - ))} - - )} - - {/* Action buttons */} - - {isIdle && ( - handleAction('start')} - > - - {actionLoading === 'start' ? '操作中...' : '开始充电'} - - - )} - {isCharging && ( - setConfirmAction('stop')} - > - - {actionLoading === 'stop' ? '操作中...' : '停止充电'} - - - )} - setConfirmAction('door')} - > - - {actionLoading === 'door' ? '操作中...' : '开门'} - - - - - {/* Confirm dialogs */} - handleAction('stop')} - onCancel={() => setConfirmAction(null)} - /> - handleAction('door')} - onCancel={() => setConfirmAction(null)} - /> - - ) -} diff --git a/software/h5-taro/src/pages/device-detail/index.config.ts b/software/h5-taro/src/pages/device-detail/index.config.ts deleted file mode 100644 index b9f400f..0000000 --- a/software/h5-taro/src/pages/device-detail/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '设备详情', -} diff --git a/software/h5-taro/src/pages/device-detail/index.tsx b/software/h5-taro/src/pages/device-detail/index.tsx deleted file mode 100644 index 61fea70..0000000 --- a/software/h5-taro/src/pages/device-detail/index.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro, { useRouter } from '@tarojs/taro' -import { h5Api, type CabinetDetail } from '../../api' -import Loading from '../../components/Loading' -import ErrorState from '../../components/ErrorState' -import ChannelCard from '../../components/ChannelCard' -import { CabinetStatusBadge } from '../../components/StatusBadge' - -export default function DeviceDetailPage() { - const { params } = useRouter() - const id = params.id - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - // 隐藏 tabbar(非 tab 页) - useEffect(() => { - Taro.hideTabBar() - }, []) - - const fetchDetail = async () => { - if (!id) return - setLoading(true) - setError('') - try { - const res = await h5Api.getCabinetDetail(Number(id)) - setData(res.data) - } catch { - setError('加载设备详情失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchDetail() - }, [id]) - - if (loading) return - if (error) return - if (!data) return null - - return ( - - {/* Basic info */} - - - - {data.name || data.abstract_id} - ID: {data.abstract_id} - {data.imei && IMEI: {data.imei}} - - - - - - {/* Cabin boards */} - {data.cabin_boards.map((board) => ( - - - 仓板 {board.board_id} - - ({board.status === 1 ? '在线' : '离线'}) - - - - {board.compartments.map((comp) => ( - - Taro.navigateTo({ url: `/pages/compartment-detail/index?id=${comp.id}` })} - /> - - ))} - - - ))} - - ) -} diff --git a/software/h5-taro/src/pages/devices/index.config.ts b/software/h5-taro/src/pages/devices/index.config.ts deleted file mode 100644 index 9d2def3..0000000 --- a/software/h5-taro/src/pages/devices/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '设备', -} diff --git a/software/h5-taro/src/pages/devices/index.tsx b/software/h5-taro/src/pages/devices/index.tsx deleted file mode 100644 index 7fe1027..0000000 --- a/software/h5-taro/src/pages/devices/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { h5Api, type CabinetItem } from '../../api' -import Loading from '../../components/Loading' -import ErrorState from '../../components/ErrorState' -import { CabinetStatusBadge } from '../../components/StatusBadge' - -export default function DevicesPage() { - const [list, setList] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState('') - - const fetchList = async () => { - setLoading(true) - setError('') - try { - const res = await h5Api.getCabinets() - setList(res.data || []) - } catch { - setError('加载设备列表失败') - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchList() - }, []) - - if (loading) return - if (error) return fetchList()} /> - - return ( - - {/* List */} - {list.length === 0 ? ( - - 暂无设备 - - ) : ( - - {list.map((item) => ( - Taro.navigateTo({ url: `/pages/device-detail/index?id=${item.id}` })} - > - - - {item.name || item.abstract_id} - - - - ID: {item.abstract_id} - - ))} - - )} - - ) -} diff --git a/software/h5-taro/src/pages/home/index.config.ts b/software/h5-taro/src/pages/home/index.config.ts deleted file mode 100644 index 86e3b2e..0000000 --- a/software/h5-taro/src/pages/home/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '首页', -} diff --git a/software/h5-taro/src/pages/home/index.tsx b/software/h5-taro/src/pages/home/index.tsx deleted file mode 100644 index cb87795..0000000 --- a/software/h5-taro/src/pages/home/index.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { h5Api, type DashboardData } from '../../api' -import { useH5AuthStore } from '../../store/auth' -import Loading from '../../components/Loading' - -export default function HomePage() { - const user = useH5AuthStore((s) => s.user) - const isAuthenticated = useH5AuthStore((s) => s.isAuthenticated) - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) - - // 未登录跳转到登录页 - useEffect(() => { - if (!isAuthenticated) { - Taro.navigateTo({ url: '/pages/login/index' }) - } - }, [isAuthenticated]) - - const fetchDashboard = async () => { - setLoading(true) - try { - const res = await h5Api.getDashboard() - setData(res.data) - } catch { - // ponytail: silent fail on dashboard, page shows zeros - } finally { - setLoading(false) - } - } - - useEffect(() => { - fetchDashboard() - }, []) - - if (loading) return - - return ( - - {/* Welcome */} - - - {user?.name ? `${user.name},你好` : '你好'} - - 欢迎使用安知充充电柜 - - - {/* Stats Grid — flex 2x2 */} - - - {data?.online_cabinets ?? 0} - 在线设备 - - - {data?.charging_count ?? 0} - 充电中 - - - {data?.idle_channels ?? 0} - 空闲仓体 - - - - {data?.fault_channels ?? 0} - - 故障 - - - - {/* Today summary */} - - 今日概况 - - - {data?.today_charge_count ?? 0} - 充电次数 - - - {data?.today_energy_kwh?.toFixed(1) ?? '0.0'} - 充电量(kWh) - - - {data?.today_charge_hours?.toFixed(1) ?? '0.0'} - 时长(h) - - - - - {/* Fault alerts */} - {data?.alerts && data.alerts.length > 0 && ( - - 故障告警 - {data.alerts.slice(0, 5).map((alert, i) => ( - - {alert.cabinet} — 通道 {alert.channel} - {alert.project} - - ))} - - )} - - {/* Quick Actions */} - - 快捷操作 - Taro.switchTab({ url: '/pages/devices/index' })} - > - 查看设备 - - Taro.switchTab({ url: '/pages/me/index' })} - > - 个人中心 - - - - ) -} diff --git a/software/h5-taro/src/pages/login/index.config.ts b/software/h5-taro/src/pages/login/index.config.ts deleted file mode 100644 index a11f33e..0000000 --- a/software/h5-taro/src/pages/login/index.config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default { - navigationBarTitleText: '登录', - navigationStyle: 'custom', -} diff --git a/software/h5-taro/src/pages/login/index.tsx b/software/h5-taro/src/pages/login/index.tsx deleted file mode 100644 index b742550..0000000 --- a/software/h5-taro/src/pages/login/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import React, { useEffect, useState } from 'react' -import { View, Text, Input, Button } from '@tarojs/components' -import Taro, { useRouter } from '@tarojs/taro' -import { useH5AuthStore } from '../../store/auth' -import { h5Api } from '../../api' - -export default function LoginPage() { - const { params } = useRouter() - const abstractId = params.abstract_id - const [phone, setPhone] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState('') - const { login, loading } = useH5AuthStore() - - // 隐藏 tabbar(login 页不是 tab 页) - useEffect(() => { - Taro.hideTabBar() - }, []) - - const handleLogin = async () => { - if (!phone || !password) { - setError('请输入手机号和密码') - return - } - setError('') - try { - await login(phone, password) - if (abstractId) { - // 扫码进来的,登录后跳转设备详情 - try { - const res = await h5Api.getCabinetByAbstract(abstractId) - Taro.navigateTo({ url: `/pages/device-detail/index?id=${res.data.id}` }) - } catch { - Taro.switchTab({ url: '/pages/home/index' }) - } - } else { - Taro.switchTab({ url: '/pages/home/index' }) - } - } catch (e: any) { - setError(e.message || '登录失败') - } - } - - return ( - - {/* Header */} - - 安知充 - 智能充电柜管理系统 - - - {/* Form */} - - - 手机号 - setPhone(e.detail.value)} - /> - - - 密码 - setPassword(e.detail.value)} - /> - - - {error && ( - {error} - )} - - - - - ) -} diff --git a/software/h5-taro/src/pages/me/index.config.ts b/software/h5-taro/src/pages/me/index.config.ts deleted file mode 100644 index c4f5af2..0000000 --- a/software/h5-taro/src/pages/me/index.config.ts +++ /dev/null @@ -1,3 +0,0 @@ -export default { - navigationBarTitleText: '我的', -} diff --git a/software/h5-taro/src/pages/me/index.tsx b/software/h5-taro/src/pages/me/index.tsx deleted file mode 100644 index 4f76f9d..0000000 --- a/software/h5-taro/src/pages/me/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import React, { useState } from 'react' -import { View, Text } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { useH5AuthStore } from '../../store/auth' -import ConfirmDialog from '../../components/ConfirmDialog' - -export default function MePage() { - const { user, logout } = useH5AuthStore() - const [showLogout, setShowLogout] = useState(false) - - const handleLogout = () => { - logout() - Taro.reLaunch({ url: '/pages/login/index' }) - } - - return ( - - {/* User info */} - - - - {(user?.name || user?.phone || '?').charAt(0)} - - - - {user?.name || '用户'} - {user?.phone} - - - - {/* Menu items */} - - Taro.showToast({ title: '功能开发中', icon: 'none' })} - > - 修改密码 - › - - - - {/* Logout */} - - setShowLogout(true)} - > - 退出登录 - - - - setShowLogout(false)} - /> - - ) -} diff --git a/software/h5-taro/src/store/auth.ts b/software/h5-taro/src/store/auth.ts deleted file mode 100644 index 951746e..0000000 --- a/software/h5-taro/src/store/auth.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { create } from 'zustand' -import Taro from '@tarojs/taro' -import { h5Api, type H5User } from '../api' - -interface AuthState { - token: string | null - user: H5User | null - loading: boolean - isAuthenticated: boolean - login: (phone: string, password: string) => Promise - logout: () => void - restore: () => void -} - -export const useH5AuthStore = create((set) => ({ - token: null, - user: null, - loading: false, - isAuthenticated: false, - - login: async (phone: string, password: string) => { - set({ loading: true }) - try { - const res = await h5Api.login({ phone, password }) - const { token, user } = res.data - Taro.setStorageSync('h5_token', token) - Taro.setStorageSync('h5_user', JSON.stringify(user)) - set({ token, user, isAuthenticated: true, loading: false }) - } catch { - set({ loading: false }) - throw new Error('登录失败,请检查手机号和密码') - } - }, - - logout: () => { - Taro.removeStorageSync('h5_token') - Taro.removeStorageSync('h5_user') - set({ token: null, user: null, isAuthenticated: false }) - }, - - restore: () => { - try { - const token = Taro.getStorageSync('h5_token') - const raw = Taro.getStorageSync('h5_user') - if (token) { - const user = raw ? (JSON.parse(raw) as H5User) : null - set({ token, user, isAuthenticated: true }) - } - } catch { - // ponytail: storage read failure, not worth crashing over - } - }, -})) diff --git a/software/h5-taro/tsconfig.json b/software/h5-taro/tsconfig.json deleted file mode 100644 index 8e150c2..0000000 --- a/software/h5-taro/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "es2017", - "module": "commonjs", - "lib": ["es2017", "dom"], - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "noEmit": true, - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - } - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} diff --git a/software/h5-uniapp/index.html b/software/h5-uniapp/index.html deleted file mode 100644 index c1fc8e1..0000000 --- a/software/h5-uniapp/index.html +++ /dev/null @@ -1,22 +0,0 @@ - - - - - - 安知充 - - - -
- - - diff --git a/software/h5-uniapp/package.json b/software/h5-uniapp/package.json index ff96a3a..b128ecf 100644 --- a/software/h5-uniapp/package.json +++ b/software/h5-uniapp/package.json @@ -1,16 +1,14 @@ { - "name": "h5-uniapp", + "name": "mp-uniapp", "version": "1.0.0", "private": true, "scripts": { - "dev:h5": "uni", - "build:h5": "uni build" + "build:mp-weixin": "uni build --platform mp-weixin" }, "dependencies": { "@dcloudio/uni-app": "3.0.0-4060620250520001", "@dcloudio/uni-app-plus": "3.0.0-4060620250520001", "@dcloudio/uni-components": "3.0.0-4060620250520001", - "@dcloudio/uni-h5": "3.0.0-4060620250520001", "@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001", "jsqr": "^1.4.0", "pinia": "^2.1.7", diff --git a/software/h5-uniapp/src/App.vue b/software/h5-uniapp/src/App.vue index 7cbc2fd..b0100c6 100644 --- a/software/h5-uniapp/src/App.vue +++ b/software/h5-uniapp/src/App.vue @@ -5,18 +5,54 @@ import { useAuthStore } from './store/auth' onLaunch(() => { const authStore = useAuthStore() authStore.restore() - // 全局接收扫码参数 - const scanId = (window as any).__SCAN_ABSTRACT_ID__ - if (scanId) { - authStore.setScanId(scanId) - ;(window as any).__SCAN_ABSTRACT_ID__ = undefined + if (typeof window !== 'undefined') { + const scanId = (window as any).__SCAN_ABSTRACT_ID__ + if (scanId) { + authStore.setScanId(scanId) + ;(window as any).__SCAN_ABSTRACT_ID__ = undefined + } } }) diff --git a/software/h5-uniapp/src/api/index.ts b/software/h5-uniapp/src/api/index.ts index 57da8eb..9471735 100644 --- a/software/h5-uniapp/src/api/index.ts +++ b/software/h5-uniapp/src/api/index.ts @@ -1,6 +1,6 @@ -// API 层 — 复用 Taro 项目的逻辑,改用 uni.request +// API 层 — 微信小程序专用 -const BASE_URL = '/api/pms' +const BASE_URL = 'https://app.anzhizhichong.com/api/pms' interface RequestOptions { method?: string @@ -8,8 +8,19 @@ interface RequestOptions { params?: Record } +/** 登录态失效:清除本地凭证并回到登录页(带防抖,避免并发请求重复跳转) */ +let redirectingToLogin = false +function redirectToLogin() { + if (redirectingToLogin) return + redirectingToLogin = true + uni.removeStorageSync('mp_token') + uni.removeStorageSync('mp_user') + uni.reLaunch({ url: '/pages/login/index' }) + setTimeout(() => { redirectingToLogin = false }, 1500) +} + async function request(path: string, options: RequestOptions = {}): Promise { - const token = uni.getStorageSync('h5_token') + const token = uni.getStorageSync('mp_token') const header: Record = { 'Content-Type': 'application/json', } @@ -30,14 +41,25 @@ async function request(path: string, options: RequestOptions = {}): Promise, + timeout: 15000, success: (res) => { - if (res.statusCode >= 400) { - const msg = (res.data as any)?.message || (res.data as any)?.error || '请求失败' - reject(new Error(msg)) + const body = res.data as any + // token 过期/无效:清除登录态并跳回登录页(登录接口本身除外,避免死循环) + if (res.statusCode === 401 && !path.includes('/auth/')) { + redirectToLogin() + reject(new Error('登录已过期,请重新登录')) return } - resolve(res.data as T) + if (res.statusCode >= 400) { + reject(new Error(body?.message || body?.error || '请求失败')) + return + } + if (body && typeof body.code !== 'undefined' && body.code !== 0) { + reject(new Error(body.message || '请求失败')) + return + } + resolve((body?.data ?? body) as T) }, fail: (err) => { reject(new Error(err.errMsg || '网络错误')) @@ -48,11 +70,6 @@ async function request(path: string, options: RequestOptions = {}): Promise - request<{ data: LoginResult }>('/auth/login', { method: 'POST', body: params }), + wxLogin: (code: string, phoneCode: string) => + request('/auth/wx-login', { method: 'POST', body: { code, phone_code: phoneCode } }), + + updateAvatar: (avatar: string) => + request<{ avatar: string }>('/auth/avatar', { method: 'PUT', body: { avatar } }), getDashboard: (params?: { org_id?: string }) => - request<{ data: DashboardData }>('/dashboard', { params: params as Record }), + request('/dashboard', { params: params as Record }), getCabinets: (params?: { project_id?: number; org_id?: number }) => - request<{ data: CabinetItem[] }>('/cabinets', { params: params as Record }), + request('/cabinets', { params: params as Record }), getCabinetByAbstract: (abstractId: string) => - request<{ data: CabinetDetail }>(`/cabinets/abstract/${abstractId}`), + request(`/cabinets/abstract/${abstractId}`), getCabinetDetail: (id: number) => - request<{ data: CabinetDetail }>(`/cabinets/${id}`), + request(`/cabinets/${id}`), getCompartmentDetail: (id: number) => - request<{ data: CompartmentDetail }>(`/compartments/${id}`), + request(`/compartments/${id}`), startCharge: (compartmentId: number) => request<{ code: number }>('/charge/start', { method: 'POST', body: { compartment_id: compartmentId } }), @@ -203,11 +253,33 @@ export const h5Api = { request<{ code: number }>('/door/open', { method: 'POST', body: { compartment_id: compartmentId } }), getChargeRecords: (params?: { page?: number; page_size?: number }) => - request<{ data: { list: ChargeRecordOrder[]; total: number } }>('/charge-records', { params: params as Record }), + request<{ list: ChargeRecordOrder[]; total: number }>('/charge-records', { params: params as Record }), getOrganizations: () => request('/organizations'), scanCabinet: (abstractId: string) => - request<{ data: ScanResult }>(`/cabinets/scan/${abstractId}`), + request(`/cabinets/scan/${abstractId}`), + + submitFeedback: (content: string, contact?: string, images?: string[]) => + request<{ success: boolean }>('/feedback', { method: 'POST', body: { content, contact, images } }), + + uploadFeedbackImage: (tempPath: string) => + new Promise((resolve, reject) => { + const token = uni.getStorageSync('mp_token') + uni.uploadFile({ + url: `${BASE_URL}/feedback/upload`, + filePath: tempPath, + name: 'file', + header: token ? { Authorization: `Bearer ${token}` } : {}, + timeout: 30000, + success: (res) => { + let body: any + try { body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data } catch { body = null } + if (body && body.code === 0 && body.data?.url) resolve(body.data.url) + else reject(new Error(body?.message || '图片上传失败')) + }, + fail: (err) => reject(new Error(err.errMsg || '图片上传失败')) + }) + }), } diff --git a/software/h5-uniapp/src/components/AppIcon.vue b/software/h5-uniapp/src/components/AppIcon.vue new file mode 100644 index 0000000..c3de9e3 --- /dev/null +++ b/software/h5-uniapp/src/components/AppIcon.vue @@ -0,0 +1,48 @@ + + + + + \ No newline at end of file diff --git a/software/h5-uniapp/src/manifest.json b/software/h5-uniapp/src/manifest.json index 121edbf..cec3310 100644 --- a/software/h5-uniapp/src/manifest.json +++ b/software/h5-uniapp/src/manifest.json @@ -1,24 +1,18 @@ { - "name": "安知充", + "name": "艾动智能", "appid": "", "description": "智能充电柜管理系统", "versionName": "1.0.0", "versionCode": "100", "transformPx": false, - "h5": { - "title": "安知充", - "router": { - "mode": "history", - "base": "/h5/" + "mp-weixin": { + "libVersion": "3.4.10", + "appid": "wx226061c550c52e85", + "setting": { + "urlCheck": false, + "es6": true, + "minified": true }, - "devServer": { - "port": 5175, - "proxy": { - "/api": { - "target": "http://10.8.0.252:3000", - "changeOrigin": true - } - } - } + "usingComponents": true } } diff --git a/software/h5-uniapp/src/pages.json b/software/h5-uniapp/src/pages.json index d817df3..1129d60 100644 --- a/software/h5-uniapp/src/pages.json +++ b/software/h5-uniapp/src/pages.json @@ -1,19 +1,19 @@ { "pages": [ { "path": "pages/home/index", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } }, - { "path": "pages/devices/index", "style": { "navigationStyle": "custom" } }, { "path": "pages/orders/index", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } }, { "path": "pages/me/index", "style": { "navigationStyle": "custom" } }, { "path": "pages/login/index", "style": { "navigationStyle": "custom" } }, { "path": "pages/device-detail/index", "style": { "navigationStyle": "custom" } }, - { "path": "pages/compartment-detail/index", "style": { "navigationStyle": "custom" } } + { "path": "pages/compartment-detail/index", "style": { "navigationStyle": "custom" } }, + { "path": "pages/feedback/index", "style": { "navigationStyle": "custom" } } ], "tabBar": { - "color": "#999999", - "selectedColor": "#2563eb", + "color": "#94a3b8", + "selectedColor": "#0d9488", "backgroundColor": "#ffffff", "borderStyle": "black", - "iconWidth": "28px", + "iconWidth": "24px", "list": [ { "pagePath": "pages/home/index", "iconPath": "static/tabbar/home.png", "selectedIconPath": "static/tabbar/home-active.png" }, { "pagePath": "pages/orders/index", "iconPath": "static/tabbar/orders.png", "selectedIconPath": "static/tabbar/orders-active.png" }, @@ -22,8 +22,8 @@ }, "globalStyle": { "navigationBarTextStyle": "black", - "navigationBarTitleText": "安知充", + "navigationBarTitleText": "艾动智能", "navigationBarBackgroundColor": "#ffffff", - "backgroundColor": "#f5f5f5" + "backgroundColor": "#f5f7f8" } } diff --git a/software/h5-uniapp/src/pages/compartment-detail/index.vue b/software/h5-uniapp/src/pages/compartment-detail/index.vue index 2ac2e2c..975e2a4 100644 --- a/software/h5-uniapp/src/pages/compartment-detail/index.vue +++ b/software/h5-uniapp/src/pages/compartment-detail/index.vue @@ -1,91 +1,76 @@ diff --git a/software/h5-uniapp/src/pages/device-detail/index.vue b/software/h5-uniapp/src/pages/device-detail/index.vue index ad8bfa0..7a3fb1c 100644 --- a/software/h5-uniapp/src/pages/device-detail/index.vue +++ b/software/h5-uniapp/src/pages/device-detail/index.vue @@ -1,342 +1,208 @@ diff --git a/software/h5-uniapp/src/pages/feedback/index.vue b/software/h5-uniapp/src/pages/feedback/index.vue new file mode 100644 index 0000000..269ef2a --- /dev/null +++ b/software/h5-uniapp/src/pages/feedback/index.vue @@ -0,0 +1,144 @@ +