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

68 KiB
Raw Blame History

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:来自 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。

{
  "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 × 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)。

响应字段:

{
  "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(布尔,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(必填)

{
  "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禁用(禁用后指令链路拦截)',并由启动迁移幂等对齐(见下)。 ⚠️ 在线的"权威判据"是 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

响应:

{ "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 并不调用这些端点(它启动时直接写 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 (唯一权威,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 项目管理