## 背景
上一提交把 `status_post.cabinet.meter`(柜级电表读数,Wh)落库并只在读数变化时留痕
(`cabinet_meter_readings`),数据源已就绪。本提交基于它做**整柜日用电量**统计。
## ⛔ 与 energy_stats 的关系:两套口径,严禁混用
| | 数据源 | 口径 |
|---|---|---|
| **本模块** `meter_stats` | `cabinet_meter_readings`(电表读数) | **整柜实际用电**(硬件计量、含非充电损耗) |
| `energy_stats` | `charge_records`(订单结算) | **单订单充电量**(平台 `V×I×Δt` 积分的估算值) |
电表读数天然含待机/风扇/变换器损耗,且是硬件计量;订单电量是估算。两者数值必然不一致,
**谁也不替代谁**。`energy_stats` 的"今日/本月能耗"是订单口径;要"整柜每天用多少度"必须走本接口。
## 计算口径
`当日用电量 = 当日最后一条读数 − 当日之前最近一条读数`
注意是"当日**之前**最近一条",不是"当日第一条"—— 否则同日多次读数会把当天早段的用电漏掉。
## 三个语义坑(都处理了,都有单测)
1. **离线整天 = 无数据,不是 0**:当天一条读数都没有 ⇒ 该日**不出现在结果里**。
渲染成 0 度等于告诉用户"这天没用一度电",而事实是"不知道"。
2. **跨多日 ⇒ 标 `covers_multiple_days`**:前值距当日首读超过 1 天(柜子离线了几天)⇒
差值实际覆盖多天,直接标"当日用电"会高估。
3. **读数变小 ⇒ 标 `meter_reset_or_replaced`**:差值为负,这不是"负用电量",
而是电表被更换或清零 —— 必须标记,绝不当成用电量展示。
另外:**首日无前值 ⇒ `usage_wh` 不出现**(`no_baseline`),也是"无数据"不是 0。
## 汇总口径
`summary.total_wh` 只累加**可信**的日用电 —— `no_baseline`(无对比)与
`meter_reset_or_replaced`(差值非法)都不计入,否则总数会被一个换表日的巨大负值污染。
同时给出 `counted_days`(实际计入)与 `days_with_data`(有读数,含被排除的)。
## 为什么用 Rust 纯函数而不是全 SQL 算
三个语义坑都需要条件分支,SQL 里写会变成一层套一层的 `CASE WHEN`,难读也难单测。
这里 SQL 只负责取数(区间读数 + 区间**之前**最近一条作基准),判定交给
`compute_daily_usage` 纯函数 —— 可完整单测,包括三个坑各自的用例。
## 接口
`GET /api/cabinets/{id}/meter-usage?start=&end=`(闭区间,`YYYY-MM-DD`,缺省最近 30 天,最多 366 天)
权限 `device:view` + `scope_guard::verify_cabinet_ownership`(组织隔离 + 数据范围)。
## 实测(测试环境真实 HTTPS,临时超管账号+自签 JWT,用完即删)
造 5 条读数(day1=1000 / day2=1100,1300 / day3 离线 / day4=1500 / day5=1200):
```
2026-03-01 end=1000 usage=None flags=['no_baseline']
2026-03-02 end=1300 usage=300 flags=[]
2026-03-04 end=1500 usage=200 flags=['covers_multiple_days']
2026-03-05 end=1200 usage=-300 flags=['meter_reset_or_replaced']
summary: total_wh=500 (300+200,换表日不计), counted_days=2, days_with_data=4
⇒ 三个坑全部生效,03-03 缺席,汇总正确
```
## 判据自证能失败
撤掉"跳过区间前基准读数"那段(**我先前真实踩过的 bug**:不跳过则 `idx` 卡在基准上,
首日整天读数取不到)→ 3 个测试 **FAILED**;还原 → 9 passed。
## 验收
- `cargo check --all-targets`:**0 error / 0 warning**(两服务)
- `cargo test --bin pms-api`:**204 passed**(195 + 9 条新单测)
- web `npm run typecheck`:exit 0
- `device_status_parse` 16/16、`rssi_signal` 9/9
- 结构漂移:**无漂移**(两库 159 列 / 0 TIMESTAMP)
- `schema_alignment_e2e --env dev`:通过;`tcp_e2e_smoke`:**18/18**
- 路由:**81** 条 / **94** 对(新增 1 条,`docs/tech/API.md` 计数已同步 80→81、93→94)
- 已发布测试与生产(生产 0 ERROR、域名 401/200、消费链探测通过)
## 文档
- `docs/tech/API.md`:新增接口详情(含 flags 表格、离线≠0、汇总口径),更新路由计数
- `AGENTS.md`:数据模型节补"两套电量口径严禁混用" + 三个语义坑
- `tasks/002`:标记日用电量统计已完成
68 KiB
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(...)注册,共 81 行,与Select-String -Path software\api-server\src\routes\mod.rs -Pattern '\.route\(' | Measure-Object得到的 81 条完全一致。同一次注册里链式绑定多个方法时,"方法"列列出全部方法(如GET / POST), 权限列按方法分别标注。权限码一律取自
db/migrate.rs::seed_permissions的种子表(共 31 个权限码)。 处理器内部没有调用check_permission的接口,如实标注为**"仅需登录"(只过认证中间件) 或"免认证"**(公开路由),不臆造权限码。⚠️
mod.rs里还有一条.nest_service("/uploads", ServeDir::new("uploads"))(静态头像目录,免认证), 它不是.route(...)注册,故不计入上表的 81 条。上表 81 行按
(方法, 路径)展开后共 94 对,与mod.rs的 94 对逐条双向一致 (用(方法,路径)多重集比对,不按路径合并 —— 例如/api/cabinets被注册两次:cabinet_routes里POST建柜、h5_routes里GET小程序设备列表,二者是两条独立路由)。
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| 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 不调用,见下) | 仅总管理员(role_level >= 2) |
| POST | /api/nodes/{node_id}/heartbeat |
节点心跳 | 仅总管理员 |
| POST | /api/nodes/{node_id}/deregister |
节点注销(会真删注册表条目) | 仅总管理员 |
| GET | /api/nodes |
节点列表(含内网 ip:tcp_port) |
仅总管理员 |
| 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/cabinets/{id}/meter-usage |
整柜日用电量(电表读数口径,与 energy-stats 的订单电量口径是两套数据) |
device:view |
| 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} |
仓体/通道详情(实时通道状态 + 充电记录) | 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/organizations、GET /api/my-organizations |
[...] 数组 |
⚠️ data 直接是组织数组(字段 id/name) |
GET /api/cabinets(小程序) |
[...] 数组 |
⚠️ 无任何包装,也没有分页参数;与 GET /api/cabinets/filter 的 {total,data} 不同 |
GET /api/cabinets/options、GET /api/cabin-boards/options、GET /api/compartments/options |
[...] 数组 |
⚠️ data 直接是数组 |
GET /api/organization-tree |
[...] 树形数组 |
根节点数组 |
GET /api/cabinets/realtime-status |
{device,boards} |
⚠️ 无列表/分页 |
GET /api/energy-stats/summary |
{today_energy,month_energy,estimated_cost,cabinet_count,online_count} |
单对象 |
GET /api/users/{id}/scopes |
[...] 数组 |
⚠️ data 直接是范围记录数组(带 valid) |
⚠️ 内部接口例外:
/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 |
仓体详情 / 通道状态 | mV | / 1000 → V |
current |
仓体详情 / 通道状态 | mA | / 1000 → A |
temp |
仓体详情 / 设备列表 / 设备详情(柜温与电芯最高温同口径) | K×100 | / 100 - 273.15 → °C |
meter |
设备级 Redis(协议 cabinet.meter,未在 HTTP 响应透出) |
Wh(电表读数) | / 1000 → kWh |
rssi |
设备列表 / 通道状态 | 0–99 信号等级(不是 dBm) | 直接显示 |
estimated_cost |
能耗概览 | 元(f64,业务计算值) |
不转换 |
cell_voltages[] / cell_temps[] |
仓体详情 | ⚠️ 恒为空数组(无写入方,保留字段类型) | — |
完整单位规范见文末 数据单位规范;后端不做任何单位换算,一律返回原始整数值 (唯一例外是
estimated_cost,见GET /api/energy-stats/summary)。
认证接口
POST /api/auth/login
请求:
{
"phone": "13800138000",
"password": "admin123"
}
响应:
{
"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)。
{
"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 参数按组织过滤。
响应:
{
"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/my-organizations
小程序「选择组织」弹层专用:返回当前用户可见的组织列表。
- 权限:仅需登录(
routes/h5.rs::list_my_organizations里没有check_permission调用), 不校验org:view—— 操作员的授权清单里没有该码,若沿用管理端GET /api/organizations(硬校验org:view)会 403、组织弹层永远为空; - 隔离规则(
visible_org_scope,按role_level+users.organization_id): 工厂(role_level >= 2)全量;组织管理员1/ 操作员0仅users.organization_id指向的那一个;organization_id为NULL→ 空数组(不是错误); - 与管理端
GET /api/organizations的分工不可混用:后者语义冻结(org:view+ 全量)。
响应: data 直接是组织数组(与管理端同构,字段 id / name):
{
"code": 0,
"data": [
{ "id": 1, "name": "无锡某某换电科技有限公司" }
],
"message": "ok"
}
GET /api/cabinets
设备列表,支持 project_id 和 org_id 过滤。响应包含实时 RSSI。
⚠️
data是数组本身({"code":0,"data":[...],"message":"ok"}),不是{list,total}包装, 也没有分页参数 —— 与GET /api/cabinets/filter的{total,data}完全不同,前端不要套通用分页逻辑。
数组元素字段(依据 routes/h5.rs::build_cabinet_list):
{
"id": 1,
"abstract_id": "10-00000001",
"name": "太湖新城换电柜A",
"imei": "864720562557021",
"iccid": "8986012520983901841",
"status": 0,
"project_id": 1,
"address": "无锡市新吴区…",
"latitude": "31.550000",
"longitude": "120.360000",
"channel_count": 36,
"idle_count": 30,
"charging_count": 6,
"full_count": 0,
"fault_count": 0,
"rssi": 85,
"pow_fail_dc": false,
"pow_fail_ac": false,
"temp": 29800,
"status1": "10",
"status2": null,
"org_disabled": false
}
status:柜子级状态,沿用cabinets.status(0/1/2,2=禁用,见PUT /api/cabinets/{id}/status),不是仓体的0/1/2/3派生档;idle_count/charging_count:来自 DBcompartments.status的占用值(0/1), 不做派生覆盖(derive_channel_counts派生出的 idle/charging 分区不写回响应);full_count/fault_count:由 Redis 通道状态派生(batt_statusbit0=充满、 bit4/5/6 或pow_statusbit0=故障);设备离线/无通道数据时恒为 0(无法判定,不猜测);status1/status2:柜级状态位(协议 §3.8),2 位小写 hex 原样透出, 取不到为null(不是"00") ——"00"是"全部正常"的有效值,两者语义不同;temp:柜温,单位 K×100(29800= 25.5 °C),取不到为null;pow_fail_dc/pow_fail_ac:布尔,取不到为null;org_disabled:所属项目对应组织是否被禁用(前端徽标用)。
GET /api/cabinets/abstract/{abstract_id}
扫码入口,免认证(注册在 mod.rs::public_routes(),不经过认证中间件;
mod.rs:211 → h5::get_cabinet_by_abstract_id)。通过二维码中的抽象 ID 查找设备。
响应(最小字段集): data 恰好 5 个键 —— id / abstract_id / name / status / cabin_boards;
cabin_boards[] 每项 4 个键 —— id / board_id / status / compartments[];
compartments[] 每项 3 个键 —— id / channel_id / status。
{
"code": 0,
"data": {
"id": 1,
"abstract_id": "10-00000001",
"name": "太湖新城换电柜A",
"status": 0,
"cabin_boards": [
{
"id": 1,
"board_id": 1,
"status": 0,
"compartments": [
{ "id": 1, "channel_id": 1, "status": 0 }
]
}
]
}
}
🔓 为什么必须是"最小集":本接口没有 token 也能调,所以响应刻意只给扫码进入充电流程必需的字段 ——
id(后续POST /api/charge/start/POST /api/door/open要用的内部主键)与拓扑, 用来渲染"哪个仓可用"。不含任何设备身份或归属信息:imei/iccid/auth_str(敏感凭据)/project_id/organization_id/address/latitude/longitude/rssi/ 实时状态。依据:h5.rs::get_cabinet_by_abstract_id的 SQL 只SELECT id, abstract_id, imei, name, status,且把imei绑到_imei后丢弃,其余列根本没查。 需要完整设备信息请走需认证的GET /api/cabinets/{id}。 这里的仓体status是 DB 原始占用值(0/1),未做实时派生(本接口不读 Redis)。
🔒
abstract_id可枚举 —— 已加 nginx 限流(2026-09 上线,含实测依据)格式固定为
10-%08d(如10-00000001),即 1 亿个可穷举的连续 ID;本接口免认证, 攻击者无需 token 即可逐个探测"柜子是否存在 + 柜名 + 仓体结构"。 现已由 nginx 限流兜住:# /etc/nginx/nginx.conf limit_req_zone $binary_remote_addr zone=scan_abstract_zone:10m rate=30r/m; limit_req_status 429; # conf.d/{app,test}.anzhizhichong.com.conf(两份都配了) location /api/pms/cabinets/abstract/ { limit_req zone=scan_abstract_zone burst=20 nodelay; proxy_pass http://127.0.0.1:9000/api/cabinets/abstract/; # 测试域为 :3000 }
zone=scan_abstract_zone,rate=30r/m+burst=20 nodelay(比登录/验证码宽松得多: 正常扫码是"一次扫码 1~2 个请求",同一 IP 连扫 20 次不排队),超限返回429(limit_req_status 429);- 实测(2026-09,本机同 IP 连发 60 次
GET /api/pms/cabinets/abstract/10-99999999):
test.anzhizhichong.com:404× 23 +429× 37app.anzhizhichong.com:404× 23 +429× 37 (取证前先单发一次得404,确认 1 分钟窗口已回填、不是"假拦截");- 23 次通过 ≈
burst=20+ 窗口内回填,符合配置预期;nginx -t通过;仓库镜像deploy/nginx/{app,test}.anzhizhichong.com.conf与线上一致。- ⚠️ 顺序前提:本节的
location是前缀匹配,/api/pms/cabinets/abstract/与 需认证的/api/pms/cabinets/scan/是两条独立路径,限流只覆盖前者。
GET /api/compartments/{id}
仓体/通道详情,含通道实时状态与充电记录(routes/h5.rs::get_compartment_detail)。
响应字段:
{
"code": 0,
"data": {
"id": 1,
"channel_id": 1,
"cabin_board_id": 1,
"status": 2,
"voltage": 54600,
"current": 0,
"energy": null,
"soc": 1000,
"temp": 29800,
"batt_status": "81",
"pow_status": "02",
"cell_voltages": [],
"cell_temps": [],
"bms_alerts": [],
"charge_records": []
},
"message": "ok"
}
status— 派生值0 空闲 / 1 充电中 / 2 充满 / 3 故障(优先级fault > full > charging > idle, 由 Redis 通道batt_status/pow_status经cabinet_tree::derive_channel_flags推导)。 拿不到通道实时数据时回落到 DB 的占用值0/1(离线无法判定,不猜测); 没有"禁用"这一档(仓体级禁用不存在)。voltage— mV(整数);current— mA(整数);temp— K×100(整数);soc— ‰(0–10000,前端/ 10转 %)。取不到为null,绝不落0。batt_status/pow_status— 通道 18 字符 hex 的 byte0 / byte1,2 位小写 hex 原样透出 (位定义见协议 §3.8),取不到为null。energy— 恒为null:通道级 Redis Hash 没有累计电量字段(累加器是订单维度的内部键), 此处如实返回 null 而不是0。cell_voltages/cell_temps/bms_alerts— 恒为空数组[]: 协议未上报 BMS 单体明细,当前没有任何写入方,保留字段仅为维持既有前端类型(number[]/string[])。
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 请求体:
{
"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 标记该行是否会被鉴权消费):
{
"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/batteries |
间接归属:battery_id → charge_orders → cabinets.project_id(叠加在同一个 EXISTS 子查询里;语义见该接口一节) |
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 | 结束时间(含),格式同上 |
响应:
{
"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": 0,
"status_text": "正常"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"message": "ok"
}
状态码: 0 正常 / 1 异常 / 2 手动停止(仅 3 档;charge_records.status 列注释即
0正常 1异常 2手动停止,routes/charge_records.rs::status_text 逐值对应,越界返回 "未知")。
⚠️ 历史文档曾写成"0=充电中,1=正常结束,2=异常中断,3=手动停止",四档且值全错 ——
charge_records 没有"充电中"档(充电中的会话在 charge_orders,status=0)。
单位: 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。
{
"code": 0,
"data": {
"total": 2,
"data": [
{
"id": 1,
"abstract_id": "10-00000000",
"name": "太湖新城换电柜A",
"status": 1,
"is_online": true,
"rssi": 85,
"pow_fail_dc": false,
"pow_fail_ac": false,
"status1": "10",
"status2": null,
"idle_count": 30,
"charging_count": 6,
"full_count": 2,
"fault_count": 0
}
]
},
"message": "ok"
}
⚠️ 前端解析时不要套用
{list,total,page,page_size}的通用分页逻辑。
- 在线字段名是
is_online(布尔,Redisonline == "1";Redis 不可用/无键时为null), 不是online;status1/status2:柜级状态位,2 位 hex 原样透出,取不到为null(不是"00");idle_count/charging_count:DB 占用值(compartments.status的0/1),不被派生覆盖;full_count/fault_count:由 Redis 通道状态派生后覆盖 SQL 返回值, 设备离线/无通道数据时按 0 计(cabinet_tree::apply_derived_counts);- 查询参数:
project_id/organization_id(含其直接子组织)/bound("1"已绑定 /"0"未绑定)。
🔒
?project_id=/?organization_id=是「纯筛选参数」,不构成归属边界(2026-09 修复): 它们只表达"我想看哪个项目/组织",决定"能不能看"的永远是另外两道,且无条件叠加:
- 组织隔离(
auth::org_condition_for_project)—— 总管理员豁免(全量); 客户侧收窄到users.organization_id;无组织则1=0(空集,绝不退化成全量);- 数据范围(
user_scopes项目白名单,effective_scope())—— 未配置 = 不限制。三道条件是
AND关系取交集(书写顺序 = 占位符顺序 = 绑定值顺序,见build_filter_where)。 传别人的project_id/organization_id只会得到{"data":[],"total":0},不会越权。total与列表走同一个where_clause,不存在"总数含不可见行"。⚠️ 修复前的真实越权(历史缺陷,勿再退回):旧实现把这两个入参直接当作唯一过滤条件 (
WHERE c.project_id = ?/JOIN projects ... WHERE p.organization_id = ?), 没有叠加组织隔离 —— 客户账号只要传别人的project_id,就能拿到对方柜子的全字段 (含imei/iccid/address/ 经纬度)。这与数据范围user_scopes无关: 未配置 scope 的用户同样能被利用。现已改为"入参只当筛选、边界无条件叠加"。
GET /api/cabinets/realtime-status
单设备实时状态(板级 + 通道级),权限 device:view + 设备归属校验
(非总管理员必须命中「设备 → 项目 → 组织」链路,且项目落在数据范围内,否则 403)。
查询参数: imei(必填)
{
"code": 0,
"data": {
"device": {
"online": true,
"rssi": 85,
"pow_fail_dc": false,
"pow_fail_ac": false
},
"boards": {
"1": {
"status_hex": "820281028002",
"channels": {
"1": { "on": true, "full": false, "fault": false, "status": "charging",
"hex": "8202", "batt_status": "82", "pow_status": "02",
"soc": 4500, "voltage": 54600, "current": 0, "temp": 29800 }
}
}
}
},
"message": "ok"
}
device只有 4 个字段:online(布尔,缺失时false而不是null)、rssi(取不到为null)、pow_fail_dc/pow_fail_ac(缺失时false)。 ⚠️ 本接口不返回cabinet_status1/2,也不返回柜温temp—— 要柜级状态位/柜温请用GET /api/cabinets/{id}或GET /api/cabinets(小程序列表);boards的键是仓控板号字符串("1"…),channels的键是通道号字符串;status_hex不是 Redis 字段:它由该板各通道的batt_status + pow_status(各 2 位 hex)按通道号升序拼回(cabinet_tree::assemble_boards);channels里只出现 Redis 中真实存在的通道键(设备从未上报 / 键已失效的通道整条省略), 不会凭空多出"全空通道";- 通道内
on/full/fault/hex/status都是由batt_status/pow_status派生的响应字段 (Redis 里没有这些字段名),推导规则见Redis 实时状态存储。
PUT /api/cabinets/{id}/work-mode
设置柜子充电工作模式。权限 device:edit + 组织归属校验。
请求: { "work_mode": "0" }(字符串,前端契约)—— 白名单取值,"0"=平台指令模式(默认)、
"1"=自动充电模式;其它值(含 "2"/"true")返回 400。
响应: { "code": 0, "data": { "success": true }, "message": "ok" }
GET /api/cabinets/{id}
设备详情(柜级 + 仓控板 + 仓体),权限 device:view + 组织归属校验(scope_guard)。
关键字段与语义:
| 字段 | 说明 |
|---|---|
status |
柜子级状态,原样取自 cabinets.status(2 = 禁用,见下方 PUT .../status)。⚠️ 不是派生值、不是 0/1/2/3 档 |
status1 / status2 |
柜级状态位(协议 §3.8),2 位小写 hex 原样透出(如 "10" = 柜门打开、"82" = 喷淋+烟雾);取不到为 null,绝不落 "00"("00" 是"全部正常"的有效值,两者必须区分) |
temp |
柜温,K×100(Redis device:{imei}.temp);取不到为 null(前端显示 --°C),不落 0 |
rssi / pow_fail_dc / pow_fail_ac |
取不到均为 null |
work_mode |
字符串 "0" / "1",无值时为 null |
cabin_boards[].compartments[].status |
仓体级派生值 0 空闲 / 1 充电中 / 2 充满 / 3 故障(由通道 batt_status / pow_status 经 derive_channel_flags 推导,优先级 fault > full > charging > idle);拿不到通道数据时保留 DB 占用值 0/1;没有"禁用"档 |
compartments[].batt_status / pow_status |
2 位 hex,取不到为 null |
compartments[].soc / voltage / current / temp |
单位同下表(‰ / mV / mA / K×100) |
🔎 两个
status不是一回事,前端必须分清: 顶层的status是柜子的(0/1/2,含"禁用");cabin_boards[].compartments[].status是仓体的派生展示值(0/1/2/3,无"禁用")。cabinets.status/compartments.status两列在 DB 里都只表达"占用/启用", "充满"/"故障"不落库,永远由 Redis 通道状态实时派生。
PUT /api/cabinets/{id}/status
禁用 / 启用柜子。权限 device:edit + 组织归属校验,且仅总管理员(role_level=2)可执行,
否则 403「仅总管理员可禁用/启用柜子」。
请求: { "status": 2 }(2 = 禁用,其它任何值都按"启用"处理,
routes/cabinets.rs::set_cabinet_status 的 if body.status == 2 分支);
响应: { "code": 0, "data": null, "message": "ok" }
⚠️
cabinets.status = 2是指令链路拦截的判据:h5.rs::ensure_cabinet_enabled与CABINET_DISABLED_SQL(SELECT COUNT(*) > 0 FROM cabinets WHERE id = ? AND status = 2) 据此拒绝开合闸/开门/充电。 ⚠️ 列注释已对齐(本轮修复):db/migrate.rs里cabinets.status的注释原为'0离线 1在线'(漏了2),代码里实际是0离线 /1在线(energy-stats/summary的online_count即按status = 1计数)/2禁用;现已改为'0离线 1在线 2禁用(禁用后指令链路拦截)',并由启动迁移幂等对齐(见下)。 ⚠️ 在线的"权威判据"是 Redisdevice:{imei}的 TTL 300s(协议 §2.3),cabinets.status只是库里的一个静态标记,两者不保证同步。
🔧 注释口径的落库方式(
cabinets.status与charge_orders.status同一套):migrate.rs::run()用ensure_column_comment(pool, 表, 列, marker, ddl)只在现有注释不含marker时才 ALTER(marker 分别是2禁用/保留未使用),因此重启不会每次做 DDL; 老库(含生产库pms)在下一次启动新版 api-server 时自动对齐。 实测:改动后首次启动打印[migrate] 更新列注释 ...,再次启动即未执行任何 DDL。
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
响应:
{ "code": 0, "data": { "task_id": 1 }, "message": "导出任务已创建" }
GET /api/charge-orders
充电订单列表。订单记录一次充电事件,含电池ID、SOC变化、结束原因。
🔒 组织隔离 + 数据范围(本轮新增):此前该接口完全没有组织隔离,任何登录用户都能看到 全部组织的订单。现在按
charge_orders.cabinet_id → cabinets.project_id → projects.organization_id过滤: 工厂(role_level >= 2)全量;客户侧仅本组织;users.organization_id为 NULL 的账号返回空集 (SQL 里追加AND 1=0,routes/charge_orders.rs::order_scope_conditions)。 组织条件之后再叠加项目级数据范围(cabinet_id IN (SELECT id FROM cabinets WHERE project_id IN (...))), 未配置范围时不产生额外条件。列表与total计数走同一套条件,不会出现"总数含不可见行"。
查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
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 | 结束时间(含) |
响应:
{
"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": "normal",
"reason_text": "正常充满",
"status": 1,
"status_text": "已完成",
"created_at": "2026-07-05 10:00:05"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"message": "ok"
}
订单状态(status): 0 充电中 / 1 已完成(终态)。
⚠️ 只有 2 档,且历史文档写的"0=充电中,1=正常结束,2=手动停止,3=异常终止,4=拔出结束"
是错的:charge_orders.status 列注释为 0充电中 1已完成 2异常,但生产代码没有任何写入点会写
2 —— 所有结束路径(设备上报 off/bat_out、换电池、10 小时超时、无电池对账)都走
report_worker::finalize_order*,order_status 恒为 ORDER_STATUS_DONE = 1
(report_worker.rs:2421、调用点 :2854 / :2970 / :2513)。
唯一的 status = 2 出现在 test/reset_fixture.py:27(一次性夹具脚本,非交付物,连 pms_dev)。
📌
2未被删除,而是标注为"保留未使用"(本轮):2有消费方 —— PC 订单页web/src/pages/charge-orders/index.tsx的筛选下拉提供<Option value={2}>异常</Option>、STATUS_COLOR[2] = 'red',routes/charge_orders.rs::status_text也把2映射成"异常"。 即"异常终止"曾被设计成一个状态,而现实现把它降级成reason里的结束原因 —— 这是产品语义缺口,不是笔误,故列注释改为'0充电中 1已完成 2异常(保留未使用:平台当前无写入方,结束原因见 reason)'(保留2、注明现状),并作为待产品决策项: 要么承认"异常"只是reason(则后续可移除 UI 里的死筛选档), 要么补上写入方(真正把异常订单落成status = 2)。
结束原因在 reason / reason_text 里,不在 status 里(charge_orders.reason 列注释
normal/user_stop/forced/timeout;routes/charge_orders.rs::reason_text):
reason |
reason_text |
触发场景 |
|---|---|---|
normal |
正常充满 | 设备上报充满结束 |
user_stop |
手动停止 | 平台下发 off(小程序/管理端停止充电) |
forced |
强制结束 | 拔电池 / 异常中断等强制收尾 |
timeout |
超时结束 | 订单 10 小时超时兜底结单 |
| 其它 / NULL | 未知 | — |
单位: energy = Wh(整数);start_soc / end_soc = ‰(整数,0–10000,前端 / 10 显示 %)。
end_soc 可为 null(订单未结束或收尾时取不到末帧 SOC)。
GET /api/batteries
电池台账列表(batteries 表)。每块电池一行,记录 battery_id / 当前电量 / 状态;
插入 / 拔出事件本身在 charge_orders(订单)里体现,本表不记录事件流水。
查询参数:
| 参数 | 类型 | 说明 |
|---|---|---|
page |
int | 页码(默认 1) |
page_size |
int | 每页条数(默认 20,最大 100) |
status |
int | 状态过滤 |
battery_id |
string | 电池ID(模糊匹配) |
响应:
{
"code": 0,
"data": {
"list": [
{
"id": 1,
"battery_id": "BAT001",
"soc": 4500,
"brand": "星恒",
"model": "XH-48V",
"sn": "SN123456",
"status": 1,
"status_text": "使用中",
"created_at": "2026-07-01 08:00:00"
}
],
"total": 50,
"page": 1,
"page_size": 20
},
"message": "ok"
}
电池状态: 0 空闲 / 1 使用中 / 2 故障 / 3 报废(batteries 表字段注释与
batteries.rs::status_text 一致;越界值返回 "未知")。
单位: soc = ‰(4500 = 45.00%,表字段注释即 电量(‰))。
⚠️ 历史文档曾把示例写成 "soc": 45,那是按"百分比"写的错误示例,实现返回的是千分比。
⚠️ 本接口没有
voltage字段(历史文档的示例里有"voltage": 52000,是臆造):batteries表只有soc,没有任何电压列(实测SHOW COLUMNS FROM batteries无voltage, 只有rated_voltage额定电压且不在响应里)。 同样不存在的响应字段还有cabinet_id/compartment_id/last_seen——BatteryItem的完整字段就是上例这 9 个(routes/batteries.rs::BatteryItem)。 需要电压/电流请走GET /api/compartments/{id}或设备详情的通道级实时字段。
🔒 组织隔离 + 数据范围:走「间接归属」(2026-09 修复)
batteries表没有organization_id/project_id/cabinet_id(也无外键、无关联列), 所以归属只能间接推导(routes/batteries.rs::build_battery_filters):batteries.battery_id → charge_orders.battery_id → charge_orders.cabinet_id → cabinets.project_id → projects.organization_id落成 SQL 是在原过滤器之前无条件追加一个
EXISTS子查询,外加数据范围条件: 总管理员(role_level >= 2)→ 不加归属条件(全量); 客户侧 →EXISTS (SELECT 1 FROM charge_orders co JOIN cabinets c ... JOIN projects p ... WHERE co.battery_id = batteries.battery_id AND p.organization_id = ?), 并在同一子查询的c.project_id上叠加数据范围(未配置 = 不限制);users.organization_id为NULL→1=0空集(绝不退化成全量)。这个归属口径的语义(必须按此理解,否则会误判"少数据"是 bug):
- 判据是「这块电池被本组织用过」—— 依据是多笔
charge_orders(设备上报bat_in/ 平台建单时写入battery_id);- ⚠️ "用过即可见",不区分"谁的电池":同一块
battery_id若被多个组织用过, 则这些组织都能看到它(归属是"多对多"的,不是独占);- ⚠️ 从未建过单的电池对客户不可见:
batteries表有行、但charge_orders里没有任何 该battery_id的记录 ⇒EXISTS不成立 ⇒ 客户查不到(这是有意为之: 无归属证据就不展示,宁可少给也不越权);总管理员不受此限。status/battery_id LIKE等过滤器排在归属条件之后(有单测钉住顺序: 归属 → 模糊 → 状态),避免绑定值错位。⚠️ 修复前该接口只做
check_permission(&user, "device:view")+SELECT * FROM batteries WHERE 1=1, 没有任何组织隔离(全文无effective_scope/org_condition)—— 任何持device:view的客户账号都能看到全部组织的电池台账。现已修复如上。
GET /api/cabinets/{id}/meter-usage
整柜日用电量(电表读数口径)。
⛔ 与
energy-stats不是一回事,严禁混用:
- 本接口数据源是
cabinet_meter_readings(电表读数变化留痕),反映整柜实际用电 (含非充电损耗,且是硬件计量);energy-stats数据源是charge_records(订单结算),只反映充电订单电量 (平台按V×I×Δt积分的估算值)。两者数值必然不一致,谁也不替代谁。
口径:当日用电量 = 当日最后一条读数 − 当日之前最近一条读数
(注意是"当日之前最近一条",不是"当日第一条",否则当天早段的用电会被漏掉)。
查询参数(均为可选):
| 参数 | 说明 |
|---|---|
start |
起始日期 YYYY-MM-DD(含);缺省 = 结束日期前 29 天 |
end |
结束日期 YYYY-MM-DD(含);缺省 = 今天;start~end 最多 366 天 |
响应:
{
"code": 0,
"data": {
"cabinet_id": 1,
"start": "2026-03-01",
"end": "2026-03-05",
"days": [
{ "day": "2026-03-01", "end_reading": 1000, "flags": ["no_baseline"] },
{ "day": "2026-03-02", "end_reading": 1300, "usage_wh": 300, "flags": [] },
{ "day": "2026-03-04", "end_reading": 1500, "usage_wh": 200, "flags": ["covers_multiple_days"] },
{ "day": "2026-03-05", "end_reading": 1200, "usage_wh": -300, "flags": ["meter_reset_or_replaced"] }
],
"summary": { "total_wh": 500, "counted_days": 2, "days_with_data": 4 }
},
"message": "ok"
}
flags(数据质量标记)—— 前端必须据此决定怎么展示:
| 值 | 含义 | 前端应怎么显示 |
|---|---|---|
no_baseline |
该柜第一条读数,无对比基准 ⇒ usage_wh 字段不出现 |
显示"无数据"(不是 0 度) |
covers_multiple_days |
前值距当日首读超过 1 天(柜子离线了几天)⇒ 本行差值覆盖多日 | 提示"覆盖 N 天",别当成单日用电 |
meter_reset_or_replaced |
读数比前值小(换表 / 清零)⇒ 差值不是用电量 | 显示"电表更换/清零",别显示负数用电量 |
⚠️ 离线整天 = 无数据,不是 0:当天一条读数都没有时,该日根本不出现在 days 里。
渲染成 0 度等于告诉用户"这天没用一度电",而事实是"不知道"。
汇总口径:summary.total_wh 只累加可信的日用电 —— no_baseline(无对比)
与 meter_reset_or_replaced(差值非法)都不计入,否则总数会被一个换表日的巨大负值污染。
counted_days 是实际计入的天数,days_with_data 是有读数的天数(含被排除的)。
GET /api/energy-stats/summary
能耗概览,按组织数据隔离返回今日/本月能耗数据。
响应:
{
"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(整数)。数据源是实时聚合charge_records(按start_time的今日/本月窗口),不是旧实现读energy_stats.stat_date;estimated_cost— 预估费用(f64,单位元,本文档唯一的非整数业务字段), 由month_energy按常量COST_PER_WH_CENT(分/Wh) 计算:(month_energy * COST_PER_WH_CENT / 1000) as f64 / 100.0(routes/energy_stats.rs::get_summary;不是COST_PER_WH,旧文档常量名写错了);cabinet_count/online_count— 可见柜子总数 / 其中在线数(cabinets.status = 1, 且同样叠加组织隔离 + 项目级数据范围)。
免认证接口与内部接口
本节全部来自
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":[{node_id,ip,tcp_port}],"count":N} |
/api/devices/stats |
GET | device:view |
{"node_count":N} |
/api/devices/{dev_id}/command |
POST | device:operate |
{"suc":1} |
🔒
/api/nodes/*于 2026-09 由"仅需登录"收紧为「仅总管理员」(routes/nodes.rs::require_super_admin, 4 个处理器全部前置;判据是role_level >= SUPER_ADMIN_ROLE_LEVEL(=2,middleware/auth.rs:297))—— 客户侧(含企业管理员role_level=1)返回403。 收紧原因:这是设备服务内部管理面 ——GET /api/nodes会返回节点的内网ip:tcp_port, 而POST /api/nodes/{node_id}/deregister真能把节点从注册表里删掉(不是软标记)。 ⚠️device-server并不调用这些端点(它启动时直接写 Redisnode:{node_id}, 见device-server/src/main.rs;且该写入含registered_at,与nodes.rs::register()写的内容不同), 全仓(含前端)没有任何调用方 —— 所以收紧不影响设备上报链路。 因此上表"设备服务节点注册"的调用方一栏也一并更正:不是 device-server 调用。
devices/*才是管理端/后端调用。nodes/*与devices/*都不返回{code,data,message}标准外层(见上表"内部接口例外")。
数据单位规范
核心原则:所有数值字段使用整数存储,禁止浮点类型 (f32/f64/DECIMAL/FLOAT)
单位转换仅在前端: API 始终返回原始单位值,前端负责转换为用户友好单位显示。后端禁止做任何单位换算。
标量字段
| 字段 | 类型 | 单位 | 前端显示 | 转换公式 |
|---|---|---|---|---|
energy / total_energy / today_energy / month_energy / energy_kwh¹ |
i64 | Wh | kWh | / 1000 |
voltage |
i64 | mV | V | / 1000 |
current |
i64 | mA | A | / 1000 |
soc / start_soc / end_soc |
i64 | ‰ | % | / 10 |
temp / max_temperature |
i64 | K×100 | °C | / 100 - 273.15 |
meter |
i64 | Wh(电表读数) | kWh | / 1000 |
rssi |
i64 | 0–99 信号等级 | 直接显示 | 不转换(不是 dBm) |
cost / estimated_cost |
i64/f64 | 元 | ¥ | 不转换(后端业务计算) |
today_charge_count / today_charge_hours |
i64 | 计数值 | 直接显示 | 不转换 |
cell_voltages[] / cell_temps[] |
— | ⚠️ 恒为空数组(协议未上报单体明细,无写入方) | — | — |
¹ 注意: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 |
125500 |
电表读数(Wh) | 125.5 kWh |
54600 |
通道电压(mV) | 54.6 V |
"82" |
通道 batt_status(hex,bit7 有电池 + bit1 充电中) |
位域由前端纯函数解析 |
⚠️ 单体电压/温度的示例值(
[...]数组)已删除:cell_voltages[]/cell_temps[]当前恒为[](协议未上报单体明细),写一个非空示例会误导前端按数组长度渲染。
TCP 设备通讯协议
TCP 设备通讯协议见 docs/protocol/柜控板固件对接文档.md (唯一权威,v1.1.1)。本文不再重复协议内容 —— 全仓只有这一份 TCP 协议规格文档。
Redis 实时状态存储
这一节是平台内部实现契约(不属于 HTTP 接口),权威来源是代码,不是本文档:
software/api-server/src/workers/report_worker.rs模块头注释(第 13–26 行)+ 写入点write_device_state/write_channel_state/save_channel_battery/handle_pow_fail, 以及消费方routes/cabinet_tree.rs、routes/cabinets.rs、routes/h5.rs。 接口只把这些 key 的字段原样透出(hex 透 hex、缺失透null),位域解析在前后端各自的纯函数里做。 ⚠️AGENTS.md的「Redis 实时状态存储」一节仍是旧口径(hex4 字符 /on·full·fault/ 板级status_hex),以本节与上述代码为准。
key 与字段(逐字段核对 report_worker.rs)
device:{imei} # 设备级 Hash,TTL 300s(= 在线判定窗口)
├─ online = "1" # 标记在线(任意上行都会刷新 TTL)
├─ rssi = "85" # 0–99 信号等级(不是 dBm)
├─ temp = "29800" # 柜温 K×100
├─ meter = "125500" # 电表读数 Wh(125.5 kWh)
├─ node_id = "node-1" # 设备连接所在节点(**device-server 写**,用于多节点指令路由)
├─ cabinet_status1 = "10" # 柜级状态位1,2 位小写 hex(取不到 → 不写字段)
├─ cabinet_status2 = "00" # 柜级状态位2,2 位小写 hex(取不到 → 不写字段)
├─ pow_fail_dc = "0"/"1" # 直流停电(pow_fail_dc 指令置 1;status1 bit0=0 时清 0)
└─ pow_fail_ac = "0"/"1" # 交流停电(同上)
device:{imei}:board:{板号}:ch:{通道号} # 通道级 Hash(板号/通道号**十进制不补零**,写入时不设 TTL)
├─ batt_status = "82" # 通道 18 字符 hex 的 byte0,2 位小写 hex
├─ pow_status = "02" # 通道 18 字符 hex 的 byte1(与电池状态相互独立)
├─ soc = "4500" # ‰
├─ voltage = "54600" # mV
├─ current = "0" # mA
└─ temp = "29800" # 电芯最高温 K×100
device:{imei}:board:{板号}:ch:{通道号}:bat # 最近一次 bat_in 的电池登记(内部键,TTL 48h)
├─ battery_id / soc / info # info = 18 字符 hex(与通道 hex 同布局)
├─ brand_code / model_code / material_code # 属性原值透传
└─ cell_count / rated_voltage / rated_capacity # 额定参数(mV / mAh)
device:{imei}:board:{板号}:ch:{通道号}:order # 订单电量累加器(内部键,energy.rs)
├─ mwh / maxtemp / ts / nobat # 毫瓦时累计 / 最高温 K×100 / 上次采样秒 / 连续无电池次数
已废弃、代码里不存在的旧 key/字段(旧文档曾记录,勿再引用):
- 板级 Hash
device:{imei}:board:{板号}及其status_hex—— 新协议板级没有状态字节;routes/cabinet_tree.rs的status_hex是按通道batt_status + pow_status升序拼回来的响应字段, 不是 Redis key; - 通道 hash 里的
on/full/fault/hex—— Redis 里没有这些字段, 它们只作为接口响应字段由derive_channel_flags从batt_status/pow_status实时推导 (hex= 通道 18 字符 hex 的前 4 个字符,缺任一字节则为空串); - 通道 hash 里的
battery_id/brand/model/sn—— 已迁到:bat内部键 (且品牌/型号存的是brand_code/model_code原值,"名称映射"用的brand/model列代码表未定, 目前为空); - 通道 hex 的
"0001"(4 字符) —— 实际是 18 字符 = 9 字节 (CHANNEL_HEX_LEN = 18):byte0 电池状态 / byte1 电源状态 / byte2 SOC(1%) / byte3–4 电压(0.01V, 大端) / byte5–6 电流(0.01A, 大端) / byte7–8 电芯最高温(1K, 大端); 平台换算为 ‰ / mV / mA / K×100(parse_channel_hex); cell_voltages/cell_temps单体明细 —— 协议未上报,没有任何写入方, 接口恒返回[](见GET /api/compartments/{id})。
协议 bat_in 的 battery 块(不是旧文档的"BMS 字段表")
协议 §5.5 的 battery 块 = 电池属性 + info(18 字符 hex)+ cells(电芯明细 hex):
| 字段 | 落库 / 落 Redis | 说明 |
|---|---|---|
battery_id |
batteries.battery_id、:bat.battery_id |
电池唯一标识,可能为空串(BMS 读不到序列号,不影响充电) |
info |
:bat.info(原样 hex) |
恰好 18 个 hex 字符,布局与 status_post.channels[] 元素完全相同(§5.3) |
cells |
❌ 不存 | 电芯明细整段 hex,长度 cell_count × 4 + 16;平台当前不消费、不落库 |
brand_code / model_code / material_code |
batteries + :bat |
原值透传(brand / model 名称列待代码表) |
cell_count / rated_voltage / rated_capacity |
batteries + :bat |
节数 / 额定电压 mV / 额定容量 mAh |
:bat里的soc是该次bat_in的起始 SOC(‰),供on建单取起始电量 (协议里on报文不带info);bat_out时按battery_id比对后才清除登记。
队列与缓存 key(api-server / device-server 之间)
| key | 类型 | 说明 |
|---|---|---|
device:reports |
LIST | 设备上报队列,device-server LPUSH → api-server RPOP 非阻塞轮询 + sleep 200ms |
device:commands |
LIST | 下行指令队列,api-server LPUSH → device-server 消费 |
device:commands:dead |
LIST | 死信队列 |
pending:{imei} |
Hash(TTL 120s) | 已下发指令挂起项:field = msg_id / value = act,收到带 suc 的回执时按 msg_id + act 双条件 HDEL |
auth_str:{imei} |
STRING | 安全码缓存(权威存储在 DB cabinets.auth_str,未命中回源并回填) |
auth_str:issued:{imei} |
STRING | 安全码已下发标记(下发后不可再次申领,须管理员重置) |
node:{node_id} |
Hash(TTL:device-server 用 DEVICE_REGISTER_TTL(默认 300s)、api-server 侧固定 300s,心跳刷新) |
节点注册信息:node_id / ip / tcp_port / status / registered_at / last_heartbeat;两侧都写(device-server 启动注册 + api-server 的 /api/nodes/*),GET /api/nodes 用 SCAN node:* 读取 |
读这一节时需要知道的实现细节
cabinet_status1/2取不到时"不写字段"(HSET只在normalize_hex_byte得到合法 2 位 hex 时才带这两个参数), 接口侧把"字段不存在 / 空串 / 纯空白"统一判为null,绝不落"00";pow_fail_dc/pow_fail_ac是一条指令一个字段(act即字段名,不再用info区分), 且status_post的cabinet.status1bit0 = 0(正常)时会被清成"0", bit0 = 1 时不动(避免抹掉刚置位的停电标记);- 板号量程 1–15、通道号量程 1–8(协议 §3.7,不是 0–7);
越界的板/通道整条跳过并记 WARN,不会写出
:board:0/:ch:9这类垃圾 key; - 通道 hex 长度 ≠ 18 或含非 hex 字符 → 该通道跳过(记 WARN),不影响同板其它通道。
权限码列表
以
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 |
项目管理 |