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