- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
833 lines
35 KiB
Markdown
833 lines
35 KiB
Markdown
# 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(...)` 注册**,共 **80** 行,与
|
||
> `Select-String -Path software\api-server\src\routes\mod.rs -Pattern '\.route\(' | Measure-Object`
|
||
> 得到的 **80** 条完全一致。同一次注册里链式绑定多个方法时,"方法"列列出全部方法(如 `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/my-organizations` | 小程序「选择组织」:当前用户**可见**的组织列表(工厂全量 / 客户仅本组织) | 仅需登录(不校验 `org:view`,内部按 `role_level` + `users.organization_id` 隔离) |
|
||
| 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/<uuid>.jpg" }, "message": "ok" }`
|
||
(文件落盘为 `uploads/avatars/<uuid><ext>`,同时写回 `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" }`。
|
||
|
||
---
|
||
|
||
## 管理端接口
|
||
|
||
### 用户数据范围(`user_scopes`)— PUT / GET `/api/users/{id}/scopes`
|
||
|
||
**权限**:`PUT` 需 `user:edit`;`GET` 需 `user:view`(总管理员 `role_level >= 2` 因 `check_permission` 短路而全通过)。
|
||
**语义权威定义**见 `AGENTS.md`「组织架构 → 数据范围(user_scopes)」。
|
||
|
||
**范围语义(四条硬规则,不得擅改):**
|
||
|
||
| 情形 | 行为 |
|
||
|---|---|
|
||
| 未配置任何记录 | **不限制**(沿用组织隔离,与未引入该机制时完全一致)—— 绝不是"无权限" |
|
||
| 已配置(≥1 条有效) | 在组织隔离基础上**再收窄**(交集),绝不放大可见范围 |
|
||
| 总管理员(`role_level >= 2`) | **豁免**:始终可见全量,配置保存但不生效 |
|
||
| 有记录但无一条有效(脏数据/设备级/跨组织/项目已删) | 保守忽略无效行;若一条有效都不剩 ⇒ 按"未配置 = 不限制"处理并记 `WARN` |
|
||
|
||
**粒度到项目级**:`scope_type` `1`=组织(只允许配成本人所属组织,展开为该组织全部项目)、
|
||
`2`=项目(**实际生效粒度**)、`3`=设备/柜子(**不参与鉴权**,读取时忽略、不生成任何过滤条件)。
|
||
|
||
**PUT 请求体**:
|
||
|
||
```json
|
||
{
|
||
"scopes": [
|
||
{ "scope_type": 2, "scope_id": 12, "permission_type": 1 }
|
||
]
|
||
}
|
||
```
|
||
|
||
| 字段 | 说明 |
|
||
|------|------|
|
||
| `scope_type` | `1` 组织 / `2` 项目 / `3` 设备(设备级存档但不参与鉴权) |
|
||
| `scope_id` | 对应资源 ID,**必须存在且属于该用户所在组织**,否则 `400` |
|
||
| `permission_type` | `1` 查看 / `2` 操作(当前两者都只定义"可见边界",不区分读/写) |
|
||
|
||
- 传 `"scopes": []` = **清空范围** = 恢复"不限制";
|
||
- 全量替换,在一个事务内执行(校验全部通过前不删旧数据);
|
||
- 校验失败示例:`400` `{"code":400,"message":"项目 99 不属于该用户所属组织,无法配置为其数据范围"}`;
|
||
目标用户不存在 → `404`;`scope_type`/`permission_type` 越界 → `400`。
|
||
|
||
**GET 响应**(返回库里**存量**记录,另附 `valid` 标记该行是否会被鉴权消费):
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"data": [
|
||
{ "id": 3, "scope_type": 2, "scope_id": 12, "permission_type": 1, "valid": true },
|
||
{ "id": 4, "scope_type": 3, "scope_id": 7, "permission_type": 1, "valid": true }
|
||
],
|
||
"message": "ok"
|
||
}
|
||
```
|
||
|
||
> `valid` 的含义是「**这条记录会不会被鉴权消费**」:
|
||
> - `false` = 不会生效 —— 资源已删除 / 跨组织 / 无效类型,**或 `scope_type=3`(设备级,永远不参与鉴权)**;
|
||
> - 鉴权时这些行被忽略;管理端可据此提示管理员清理。
|
||
> `GET /api/users/{id}/permissions` 的 `scopes` 字段仍为原始数组(不带 `valid`)。
|
||
|
||
**受数据范围影响的接口**(组织条件之后再叠加项目级收窄;未配置时不产生任何额外条件):
|
||
|
||
| 接口 | 收窄落点 |
|
||
|------|----------|
|
||
| `GET /api/cabinets/filter`、`GET /api/projects/{id}/cabinets`、`GET /api/cabinets/options` | `cabinets.project_id` |
|
||
| `GET /api/cabinets/{id}`、`GET /api/cabinets/realtime-status` | 归属校验(不在范围内 → `403`) |
|
||
| `GET /api/charge-records`、`GET /api/charge-records/export` | `charge_records.cabinet_id` → 项目 |
|
||
| `GET /api/charge-orders` | `charge_orders.cabinet_id` → 项目 |
|
||
| `GET /api/device-logs` | `device_logs.cabinet_id` → 项目 |
|
||
| `GET /api/energy-stats/{summary,cabinets,projects,trend,export}` | 项目/柜子维度 |
|
||
| `GET /api/organizations/{org_id}/projects` | `projects.id` |
|
||
| 所有柜子写操作(`PUT/DELETE /api/cabinets/{id}`、`work-mode`、`status`、`bind`、`replace-imei`、`regenerate-auth`、`/api/devices/{dev_id}/command`、`/api/projects/{id}`) | `scope_guard`(不在范围内 → `403`) |
|
||
| 小程序:`GET /api/cabinets`、`GET /api/dashboard`、`GET /api/compartments/{id}`、`POST /api/charge/*`、`POST /api/door/open`、`/api/cabinets/{id}/{open,pow-on,pow-off,reset}` | 设备列表按项目过滤;详情/写操作归属校验 → `403` |
|
||
|
||
> `GET /api/operation-logs` **不叠加**数据范围:操作日志按 `user_id → users.organization_id` 归属,
|
||
> 与项目/设备无关联列,项目级范围无法映射(经确认,非遗漏)。
|
||
|
||
---
|
||
|
||
### GET /api/charge-records
|
||
|
||
充电记录列表,支持按柜子/时间过滤,响应含组织数据隔离。
|
||
|
||
**查询参数:**
|
||
|
||
| 参数 | 类型 | 说明 |
|
||
|------|------|------|
|
||
| `page` | int | 页码(默认 1) |
|
||
| `page_size` | int | 每页条数(默认 20,最大 100) |
|
||
| `cabinet_id` | int | 按柜子过滤 |
|
||
| `cabin_board_id` | int | 按仓控板过滤 |
|
||
| `compartment_id` | int | 按仓体过滤 |
|
||
| `start_time` | string | 开始时间(含),格式 `YYYY-MM-DD HH:MM:SS` |
|
||
| `end_time` | string | 结束时间(含),格式同上 |
|
||
|
||
**响应:**
|
||
|
||
```json
|
||
{
|
||
"code": 0,
|
||
"data": {
|
||
"list": [
|
||
{
|
||
"id": 1,
|
||
"cabinet_id": 1,
|
||
"cabinet_name": "太湖新城换电柜A",
|
||
"cabin_board_id": 1,
|
||
"board_id": 1,
|
||
"compartment_id": 1,
|
||
"channel_id": 1,
|
||
"start_time": "2026-07-05 10:00:00",
|
||
"end_time": "2026-07-05 12:00:00",
|
||
"energy": 1200,
|
||
"max_temperature": 30435,
|
||
"status": 1,
|
||
"status_text": "正常结束"
|
||
}
|
||
],
|
||
"total": 50,
|
||
"page": 1,
|
||
"page_size": 20
|
||
},
|
||
"message": "ok"
|
||
}
|
||
```
|
||
|
||
**状态码:** 0=充电中,1=正常结束,2=异常中断,3=手动停止
|
||
|
||
**单位:** `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 设备通讯协议
|
||
|
||
> TCP 设备通讯协议见 **[柜控板固件对接文档.md](柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。
|
||
|
||
---
|
||
|
||
## 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` | 项目管理 |
|