12451 8ae55e9ca3 feat(meter): 整柜日用电量统计接口(电表读数口径,与 energy_stats 严格分离)
## 背景

上一提交把 `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`:标记日用电量统计已完成
2026-09-20 06:38:53 +08:00

1311 lines
68 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
**请求:**
```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/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`):
```json
{
"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`):
```json
{
"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`:**来自 DB `compartments.status` 的占用值**(`0`/`1`),
不做派生覆盖(`derive_channel_counts` 派生出的 idle/charging 分区不写回响应);
- `full_count` / `fault_count`:**由 Redis 通道状态派生**(`batt_status` bit0=充满、
bit4/5/6 或 `pow_status` bit0=故障);设备离线/无通道数据时**恒为 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`。
```json
{
"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 限流兜住**:
>
> ```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` × 37**
> - `app.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`)。
**响应字段:**
```json
{
"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 请求体**:
```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/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 | 结束时间(含),格式同上 |
**响应:**
```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": 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`。
```json
{
"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`**(布尔,Redis `online == "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 修复):
> 它们只表达"我想看哪个项目/组织",**决定"能不能看"的永远是另外两道**,且**无条件叠加**:
> 1. **组织隔离**(`auth::org_condition_for_project`)—— 总管理员豁免(全量);
> 客户侧收窄到 `users.organization_id`;**无组织则 `1=0`(空集,绝不退化成全量)**;
> 2. **数据范围**(`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`(必填)
```json
{
"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 实时状态存储`](#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禁用(禁用后指令链路拦截)'`,并由启动迁移幂等对齐(见下)。
> ⚠️ **在线的"权威判据"是 Redis `device:{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`
**响应:**
```json
{ "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 | 结束时间(含) |
**响应:**
```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": "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(模糊匹配) |
**响应:**
```json
{
"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 天 |
**响应:**
```json
{
"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
能耗概览,按组织数据隔离返回今日/本月能耗数据。
**响应:**
```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**(整数)。数据源是**实时聚合 `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` 并不调用这些端点**(它启动时**直接写 Redis** `node:{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](../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 实时状态存储」一节仍是旧口径**(`hex` 4 字符 / `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.status1` **bit0 = 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` | 项目管理 |