12451 db1264953a fix(ops): 恢复能力与运维缺口修复(建表顺序/备份/建单漏单/214仓体/队列上限/NOFILE)
## 恢复能力(此前实际为 0)
- migrate.rs 的 SCHEMA_SQL 存在前向引用:charge_orders 建表引用其后才创建的 compartments
  → 全新空库必然报 MySQL 1824,而 main.rs 是 .expect("数据库迁移失败") + Restart=always
  → 无限崩溃循环。修正为把 cabin_boards/compartments 移到 cabinets 之后
  (只挪位置、21 条语句字符数完全一致;实测旧版 20/21 → 新版 21/21,
   并用真实二进制在全新空库跑通、建出 21 表 153 列)
- 新增每日备份 deploy/backup-db.sh + deploy/pms-backup.cron:
  pms 与 pms_dev 各一份,--single-transaction --routines --triggers,单库 dump(无 USE/CREATE DATABASE,
  演练可安全恢复到临时库),.part 原子 mv、gzip -t 校验、记录表数与精确行数、保留 7 天、flock 防重叠、
  任一库失败则跳过清理并 exit 1(避免失败时误删旧备份)。cron 已真实触发验证;
  恢复演练:21 表逐表行数一致(1339=1339)后清理无残留
- migrate.rs 每次启动无条件 ALTER cabinets.abstract_id 改为先查 information_schema 再决定,
  实测老库触发 1 次、后续启动 0 次

## 订单与占用
- h5.rs start_charge:校验顺序改为 权限→归属→设备启用→设备在线→建单→下发(原为先建单后校验,
  设备离线时会留下幽灵单);取不到 bat_in 登记(48h TTL 过期)时不再跳过建单,改为按未知电池建单
  (与设备侧 handle_device_on 兜底一致)—— 此前会导致整段会话无订单/无记录/无电量
- report_worker.rs 新增无订单却标占用的兜底复位(优先按通道 batt_status bit7,拿不到状态时按
  超过 12 小时未更新),并已幂等清零生产历史遗留下的 214 行;两库复查均为 0

## 队列与资源上限
- device-server:device:reports 加 LPUSH→LLEN→LTRIM 0 199999→LLEN 的 pipeline 与越界 WARN
  (此前无上限:68 柜停 8 天、1000 柜停 13 小时即打满 1GB Redis,且会连累同实例既有生产平台)
- 死信队列加 LTRIM 0 9999 + EXPIRE 7 天,写入时 WARN 带累计死信数
- 两服务各加 600s 一次的队列深度 INFO 日志(可观测性)
- 4 个 systemd 单元(仓库 + 线上已安装)加 LimitNOFILE=65535(原软限制 1024,约 1000 柜即 accept 失败)

## 文档
- docs/API.md:接口总表 71 → 79 条并与 mod.rs 的 79 次 .route( 注册双向比对一致;
  修正 system:log(种子里不存在的权限码)→ operation_log:view、登出 GET→POST、能耗导出 → energy:export;
  补齐 21 个未记录接口;新增"列表响应包装"与"关键数值字段单位"两节

## 超长函数
- report_worker.rs handle_bat_out 100→63、ensure_charge_order 88→68;users.rs update_user 81→45
  (纯重构:字符串字面量多重集零差异,allow/unwrap/expect/panic 计数不变)

## 验收
- cargo check 两服务 0 error/0 warning;cargo test api 90 + device 22 全过
- 前端真实类型检查 npm run typecheck(= tsc -b;原 `tsc --noEmit` 因 tsconfig 是 solution 文件而空跑=假绿)
- 端到端联调(测试环境)13/13;生产已部署并验证:服务 active、health 200、ERROR 0、
  消费链路正常、214 仓体清零、唯一索引在位
2026-09-20 01:21:17 +08:00

40 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(...) 注册,共 79 行,与 Select-String -Path software\api-server\src\routes\mod.rs -Pattern '\.route\(' | Measure-Object 得到的 79 条完全一致。同一次注册里链式绑定多个方法时,"方法"列列出全部方法(如 GET / POST), 权限列按方法分别标注。

权限码一律取自 db/migrate.rs::seed_permissions 的种子表(共 31 个权限码)。 处理器内部没有调用 check_permission 的接口,如实标注为**"仅需登录"(只过认证中间件) 或"免认证"**(公开路由),不臆造权限码。

⚠️ mod.rs 里还有一条 .nest_service("/uploads", ServeDir::new("uploads"))(静态头像目录,免认证), 它不是 .route(...) 注册,故不计入上表的 79 条。

方法 路径 说明 权限
POST /api/auth/logout 登出 仅需登录
GET /api/auth/me 当前用户信息 仅需登录
PUT /api/auth/avatar 更新头像 URL 仅需登录
POST /api/auth/avatar/upload 上传头像文件(multipart) 仅需登录
POST /api/devices/{dev_id}/command 下发设备指令 device:operate
GET /api/devices/stats 设备服务节点统计 device:view
POST /api/nodes/register 设备服务节点注册(device-server 调用) 仅需登录
POST /api/nodes/{node_id}/heartbeat 节点心跳 仅需登录
POST /api/nodes/{node_id}/deregister 节点注销 仅需登录
GET /api/nodes 节点列表 仅需登录
GET / POST /api/roles 角色列表 / 创建角色 GET 仅需登录;POST role:create
PUT / DELETE /api/roles/{id} 更新 / 删除角色 role:edit / role:delete
PUT /api/roles/{id}/permissions 角色赋权 role:edit
GET /api/permissions 权限码列表 仅需登录
GET /api/users/{id}/permissions 用户权限(角色合并结果) user:view
PUT /api/users/{id}/roles 分配角色 user:edit
PUT / GET /api/users/{id}/scopes 设置 / 查询用户数据范围 PUT user:edit;GET user:view
GET / POST /api/organizations 组织列表 / 创建组织 org:view / org:create
PUT / DELETE /api/organizations/{id} 更新 / 删除组织 org:edit / org:delete
PUT /api/organizations/{id}/status 禁用 / 启用组织 仅总管理员(代码内 role_level=2 判断,非权限码)
GET / POST /api/organizations/{org_id}/projects 项目列表 / 创建项目 project:view / project:create
PUT / DELETE /api/projects/{id} 更新 / 删除项目 project:edit / project:delete
GET /api/projects/{project_id}/cabinets 项目下设备列表 device:view
POST /api/cabinets 批量创建柜子 device:create
PUT / DELETE / GET /api/cabinets/{id} 更新 / 删除 / 设备详情(含仓板+仓体) device:edit / device:delete / device:view
PUT /api/cabinets/{id}/status 启用 / 停用柜子 device:edit
PUT /api/cabinets/{id}/work-mode 设置充电工作模式(0=平台指令,1=自动) device:edit
POST /api/cabinets/{id}/regenerate-auth 重置安全码 device:auth_str:reset(仅总管理员)
POST /api/cabinets/{id}/open 解锁柜门(柜级,管理端设备详情页用) charge:open_door
POST /api/cabinets/{id}/pow-on 接触器合闸(柜级) device:operate
POST /api/cabinets/{id}/pow-off 接触器分闸(柜级) device:operate
POST /api/cabinets/{id}/reset 重启整柜(柜级) device:operate
POST /api/cabin-boards/{id}/reset 重启单块仓控板 device:operate
POST /api/cabinets/{id}/bind 绑定设备到项目 device:edit
POST /api/cabinets/{id}/replace-imei 更换 IMEI device:edit
GET /api/organization-tree 组织树(含项目/设备) device:view
GET /api/cabinets/filter 设备列表(管理端,响应为 {total,data},见下) device:view
GET /api/cabinets/realtime-status 单设备实时状态(板级+通道级) device:view
GET /api/cabinets/options 设备下拉选项 device:view
GET /api/cabin-boards/options 仓控板下拉选项 device:view
GET /api/compartments/options 仓体下拉选项 device:view
GET /api/charge-records 充电记录列表 charge_record:view
GET /api/charge-records/export 导出充电记录(异步任务) charge_record:export
GET /api/charge-orders 充电订单列表 charge_record:view
GET /api/batteries 电池台账列表 device:view
GET /api/device-logs 设备日志 device_log:view
GET /api/downloads 下载中心列表 charge_record:export
GET /api/downloads/{id} 下载任务详情 charge_record:export
GET /api/downloads/{id}/file 下载文件 charge_record:export
POST /api/downloads/cleanup 清理过期下载 charge_record:export
GET /api/energy-stats/summary 能耗概览 energy:view
GET /api/energy-stats/cabinets 柜子能耗排行 energy:view
GET /api/energy-stats/projects 项目能耗统计 energy:view
GET /api/energy-stats/trend 能耗趋势 energy:view
GET /api/energy-stats/export 导出能耗数据(异步任务) energy:export
GET / POST /api/users 用户列表 / 创建用户 user:view / user:create
PUT / DELETE /api/users/{id} 更新 / 删除用户 user:edit / user:delete
PUT /api/users/{id}/reset-password 重置密码 user:edit
PUT /api/users/{id}/status 设置用户状态 user:edit
GET /api/operation-logs 操作日志 operation_log:view
GET /api/dashboard 小程序首页仪表盘 仅需登录(内部按角色做组织隔离)
GET /api/cabinets 设备列表(含实时 RSSI) device:view
GET /api/compartments/{id} 仓体/通道详情(实时 BMS + 充电记录) device:view
POST /api/charge/start 开始充电 charge:start
POST /api/charge/stop 停止充电 charge:stop
POST /api/door/open 开门(柜级:整柜共用一扇门,只解锁需人拉开) charge:open_door
GET /api/cabinets/scan/{abstract_id} 扫码验证 仅需登录(组织归属校验,非权限码)
POST / GET /api/feedback 提交问题反馈 / 反馈列表 POST 仅需登录;GET operation_log:view
PUT /api/feedback/{id} 更新反馈处理状态 operation_log:view
POST /api/feedback/upload 上传反馈图片(multipart) 仅需登录
GET /api/health 健康检查 免认证
GET /api/auth/challenge 获取登录挑战码 免认证
POST /api/auth/login 登录 免认证
POST /api/auth/wx-login 微信小程序手机号快速登录 免认证
GET /api/cabinets/abstract/{abstract_id} 扫码查设备(二维码入口) 免认证
GET / POST /api/wechat/callback 微信服务器校验 / 消息回调 免认证
GET /api/maps/suggestion 腾讯地图输入提示代理 免认证
POST /api/maps/geocoder 腾讯地图逆地址解析代理 免认证
GET /api/maps/place-search 腾讯地图地点搜索代理 免认证

列表响应包装(与实现逐条核对)

data 字段的结构并非全站统一,按实现如实记录如下:

接口 data 结构 备注
GET /api/charge-records {list,total,page,page_size} 标准分页
GET /api/charge-orders {list,total,page,page_size} 标准分页
GET /api/batteries {list,total,page,page_size} 标准分页
GET /api/device-logs {list,total,page,page_size} 标准分页
GET /api/downloads {list,total,page,page_size} 标准分页
GET /api/feedback {list,total,page,page_size} 标准分页
GET /api/users {list,total} ⚠️ 没有 page / page_size
GET /api/operation-logs {list,total} ⚠️ 没有 page / page_size
GET /api/cabinets/filter {total,data} ⚠️ 与其它列表不同:列表键名是 data 而不是 list,且无 page / page_size
GET /api/energy-stats/cabinets [...] 数组 ⚠️ data 直接是数组,无分页包装
GET /api/energy-stats/projects [...] 数组 ⚠️ data 直接是数组,无分页包装
GET /api/energy-stats/trend {granularity,list} ⚠️ 无 total / page / page_size
GET /api/roles、GET /api/permissions [...] 数组 ⚠️ data 直接是数组
GET /api/organization-tree [...] 树形数组 根节点数组

⚠️ 内部接口例外:/api/nodes/* 与 /api/devices/* 直接返回 {"suc":1} / {"nodes":[...],"count":N} / {"node_count":N},不带 {code,data,message} 外层 —— 它们面向 device-server 内部调用,不是给前端消费的标准响应。

关键数值字段单位(抽查结论)

字段 出现位置 单位 前端显示
energy 充电记录 / 充电订单 / 能耗统计 Wh(整数) / 1000 → kWh
today_energy / month_energy 能耗概览 Wh(整数) / 1000 → kWh
max_temperature 充电记录 K×100(整数) / 100 - 273.15 → °C
soc / start_soc / end_soc 电池台账 / 充电订单 / 通道状态 ‰(整数,0–10000) / 10 → %
voltage / cell_voltages[] 仓体详情 / BMS mV / 1000 → V
current 仓体详情 / BMS mA / 1000 → A
rssi 设备列表 / 通道状态 0–99 信号等级(不是 dBm) 直接显示
estimated_cost 能耗概览 元(f64,业务计算值) 不转换

完整单位规范见文末 数据单位规范;后端不做任何单位换算,一律返回原始整数值。


认证接口

POST /api/auth/login

请求:

{
  "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/cabinets

设备列表,支持 project_id 和 org_id 过滤。响应包含实时 RSSI。

响应字段:

{
  "id": 1,
  "abstract_id": "10-00000001",
  "name": "太湖新城换电柜A",
  "status": 1,
  "project_id": 1,
  "rssi": 85,
  "channel_count": 36,
  "idle_count": 30,
  "charging_count": 6,
  "full_count": 0,
  "fault_count": 0
}

GET /api/cabinets/abstract/{abstract_id}

扫码入口,免认证。通过二维码中的抽象ID查找设备。

响应:

{
  "code": 0,
  "data": {
    "id": 1,
    "abstract_id": "10-00000001",
    "imei": "864720562557021",
    "iccid": "8986012520983901841",
    "name": "太湖新城换电柜A",
    "status": 1,
    "cabin_boards": [
      {
        "id": 1,
        "board_id": 1,
        "status": 0,
        "compartments": [
          { "id": 1, "channel_id": 1, "status": 0 }
        ]
      }
    ]
  }
}

GET /api/compartments/{id}

仓体/通道详情,含实时 BMS 数据和充电记录。

响应字段:

  • voltage — mV(整数)
  • current — mA(整数)
  • energy — Wh(整数)
  • soc — ‰(0-10000,前端已自动转为 %)
  • cell_voltages — mV 数组

POST /api/charge/start

请求: { "compartment_id": 123 }

权限:charge:start + 组织隔离(用户只能操作所属组织的设备)

POST /api/charge/stop

请求: { "compartment_id": 123 }

权限:charge:stop + 组织隔离

POST /api/door/open

请求: { "compartment_id": 123 }

权限:charge:open_door + 组织隔离

GET /api/cabinets/scan/{abstract_id}

扫码验证,需认证。验证当前用户是否有权访问该设备(校验组织归属;无独立权限码)。

问题反馈

接口 说明 权限
POST /api/feedback 提交反馈(content 必填,≤2000 字,可带 images) 仅需登录(任何已登录用户可提交)
GET /api/feedback 反馈列表({list,total,page,page_size}) operation_log:view
PUT /api/feedback/{id} 更新处理状态 operation_log:view
POST /api/feedback/upload 上传反馈图片(multipart/form-data) 仅需登录

POST /api/feedback 响应:{ "code": 0, "data": { "success": true }, "message": ... }; POST /api/feedback/upload 响应:{ "code": 0, "data": { "url": "..." }, "message": "ok" }。


管理端接口

GET /api/charge-records

充电记录列表,支持按柜子/时间过滤,响应含组织数据隔离。

查询参数:

参数 类型 说明
page int 页码(默认 1)
page_size int 每页条数(默认 20,最大 100)
cabinet_id int 按柜子过滤
cabin_board_id int 按仓控板过滤
compartment_id int 按仓体过滤
start_time string 开始时间(含),格式 YYYY-MM-DD HH:MM:SS
end_time string 结束时间(含),格式同上

响应:

{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 1,
        "cabinet_id": 1,
        "cabinet_name": "太湖新城换电柜A",
        "cabin_board_id": 1,
        "board_id": 1,
        "compartment_id": 1,
        "channel_id": 1,
        "start_time": "2026-07-05 10:00:00",
        "end_time": "2026-07-05 12:00:00",
        "energy": 1200,
        "max_temperature": 30435,
        "status": 1,
        "status_text": "正常结束"
      }
    ],
    "total": 50,
    "page": 1,
    "page_size": 20
  },
  "message": "ok"
}

状态码: 0=充电中,1=正常结束,2=异常中断,3=手动停止

单位: energy = Wh(整数,前端 / 1000 显示 kWh);max_temperature = K×100(整数,/ 100 - 273.15 显示 °C)。 start_time / end_time 为字符串时间,非数值单位。

GET /api/cabinets/filter

设备列表(管理端)。响应包装与其它列表接口不同:data 是 {total,data}, 列表键名是 data 而不是 list,且没有 page / page_size。

{
  "code": 0,
  "data": {
    "total": 2,
    "data": [
      { "id": 1, "abstract_id": "10-00000000", "name": "太湖新城换电柜A", "status": 1, "online": 1, "rssi": 85 }
    ]
  },
  "message": "ok"
}

⚠️ 前端解析时不要套用 {list,total,page,page_size} 的通用分页逻辑。

PUT /api/cabinets/{id}/work-mode

设置柜子充电工作模式。权限 device:edit + 组织归属校验。

请求: { "work_mode": "0" }(字符串,前端契约)—— 白名单取值,"0"=平台指令模式(默认)、 "1"=自动充电模式;其它值(含 "2"/"true")返回 400。

响应: { "code": 0, "data": { "success": true }, "message": "ok" }

PUT /api/cabinets/{id}/status

禁用 / 启用柜子。权限 device:edit + 组织归属校验,且仅总管理员(role_level=2)可执行, 否则 403「仅总管理员可禁用/启用柜子」。

请求: { "status": 2 };响应: { "code": 0, "data": null, "message": "ok" }

PUT /api/organizations/{id}/status

禁用 / 启用组织。仅总管理员(代码内 role_level < 2 判断,不走权限码); 禁用后该组织及其子组织的柜子全部不可用(指令链路拦截)。

请求: { "disabled": 1 }(only 0/1,其它值 400);响应: { "code": 0, "data": null, "message": "禁用成功" }

GET /api/charge-records/export

异步导出充电记录 Excel。创建导出任务后自行去下载中心取文件。

查询参数: 同 GET /api/charge-records

响应:

{ "code": 0, "data": { "task_id": 1 }, "message": "导出任务已创建" }

GET /api/charge-orders

充电订单列表。订单记录一次充电事件,含电池ID、SOC变化、结束原因。

查询参数:

参数 类型 说明
page int 页码(默认 1)
page_size int 每页条数(默认 20,最大 100)
cabinet_id int 按柜子过滤
compartment_id int 按仓体过滤
battery_id string 电池ID(模糊匹配)
status int 状态过滤
start_time string 开始时间(含)
end_time string 结束时间(含)

响应:

{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 1,
        "cabinet_id": 1,
        "cabinet_name": "太湖新城换电柜A",
        "compartment_id": 1,
        "battery_id": "BAT001",
        "start_time": "2026-07-05 10:00:00",
        "end_time": "2026-07-05 12:00:00",
        "start_soc": 200,
        "end_soc": 950,
        "energy": 1200,
        "reason": null,
        "reason_text": "-",
        "status": 1,
        "status_text": "正常结束",
        "created_at": "2026-07-05 10:00:05"
      }
    ],
    "total": 50,
    "page": 1,
    "page_size": 20
  },
  "message": "ok"
}

订单状态: 0=充电中,1=正常结束,2=手动停止,3=异常终止,4=拔出结束

单位: energy = Wh(整数);start_soc / end_soc = ‰(整数,0–10000,前端 / 10 显示 %)。

GET /api/batteries

电池台账列表。记录每块电池的插入/拔出事件和当前状态。

查询参数:

参数 类型 说明
page int 页码(默认 1)
page_size int 每页条数(默认 20,最大 100)
status int 状态过滤
battery_id string 电池ID(模糊匹配)

响应:

{
  "code": 0,
  "data": {
    "list": [
      {
        "id": 1,
        "battery_id": "BAT001",
        "brand": "星恒",
        "model": "XH-48V",
        "voltage": 52000,
        "soc": 4500,
        "status": 1,
        "cabinet_id": 1,
        "compartment_id": 1,
        "last_seen": "2026-07-05 12:00:00",
        "created_at": "2026-07-01 08:00:00"
      }
    ],
    "total": 50,
    "page": 1,
    "page_size": 20
  },
  "message": "ok"
}

单位: voltage = mV(52000 = 52.000V);soc = ‰(4500 = 45.00%,表字段注释即 电量(‰))。 ⚠️ 历史文档曾把示例写成 "soc": 45,那是按"百分比"写的错误示例,实现返回的是千分比。

GET /api/energy-stats/summary

能耗概览,按组织数据隔离返回今日/本月能耗数据。

响应:

{
  "code": 0,
  "data": {
    "today_energy": 45000,
    "month_energy": 1200000,
    "estimated_cost": 840.00,
    "cabinet_count": 36,
    "online_count": 34
  },
  "message": "ok"
}
  • today_energy / month_energy — Wh(整数)
  • estimated_cost — 预估费用(float,元),按 COST_PER_WH 单价计算

免认证接口与内部接口

本节全部来自 mod.rs::public_routes()(免认证,不经过认证中间件)与节点管理接口。

GET /api/health

健康检查(免认证)。

GET / POST /api/wechat/callback

微信服务器回调(免认证):GET 做服务器地址校验(回显 echostr),POST 接收消息(明文 XML)。 Nginx 有独立的 /api/wechat/ location 直连 API 服务,不经 /api/pms/ 前缀重写。

腾讯地图代理(免认证,前端需要)

接口 方法 说明
/api/maps/suggestion GET 输入提示(keyword 联想)
/api/maps/geocoder POST 逆地址解析(经纬度 → 地址)
/api/maps/place-search GET 地点搜索

代理的目的:把腾讯地图 Key 留在服务端,前端不暴露 Key。

设备服务节点与指令接口(响应不含标准外层)

接口 方法 权限 响应
/api/nodes/register POST 仅需登录 {"suc":1}
/api/nodes/{node_id}/heartbeat POST 仅需登录 {"suc":1};未注册 {"suc":0,"err":"节点未注册"}
/api/nodes/{node_id}/deregister POST 仅需登录 {"suc":1}
/api/nodes GET 仅需登录 {"nodes":[...],"count":N}
/api/devices/stats GET device:view {"node_count":N}
/api/devices/{dev_id}/command POST device:operate {"suc":1}

nodes/* 是 device-server 内部调用;devices/* 是管理端/后端调用。两者都不返回 {code,data,message} 标准外层(见上表"内部接口例外")。


数据单位规范

核心原则:所有数值字段使用整数存储,禁止浮点类型 (f32/f64/DECIMAL/FLOAT)

单位转换仅在前端: API 始终返回原始单位值,前端负责转换为用户友好单位显示。后端禁止做任何单位换算。

标量字段

字段 类型 单位 前端显示 转换公式
energy / total_energy / today_energy / month_energy / energy_kwh¹ i64 Wh kWh / 1000
voltage / cell_voltages[] i64 mV V / 1000
current i64 mA A / 1000
soc / start_soc / end_soc i64 ‰ % / 10
temp / cell_temps[] / max_temperature i64 K×100 °C / 100 - 273.15
rssi i64 0–99 信号等级 直接显示 不转换(不是 dBm)
cost / estimated_cost i64/f64 元 ¥ 不转换(后端业务计算)
today_charge_count / today_charge_hours i64 计数值 直接显示 不转换

¹ 注意:energy_kwh / today_energy_kwh 为历史遗留命名,实际值仍为 Wh,前端仍需 / 1000。

示例

原始值 含义 前端显示
52000 电压(mV) 52.0 V
10000 电流(mA) 10.0 A
120000 功率(mW) 120.0 W
1000 充电量(Wh) 1.000 kWh
850 SOC(‰) 85.0%
29865 温度(K×100) 25.5°C
85 RSSI 信号等级(0–99) 85
[32456,32500,32300] 单体电压(mV) 32.456V, 32.500V, 32.300V
[29865,29900] 单体温度(K×100) 25.5°C, 25.9°C

TCP 设备通讯协议

⛔ 本节(自本行起至下一个 ## 标题之前)全部内容已废弃,禁止作为实现依据。 唯一权威文档:柜控板固件对接文档.md(v1.1) —— 所有 TCP 报文格式、字段名、 act 清单、签名算法、保活/超时规则、时序与异常处理,一律以该文档为准。

本节保留仅为历史参考。已知与定稿规范的重大冲突(仅列关键几处,避免误读):

  • dev_id 上行全部报文必带 → 只有 auth_str / login 带,其余报文不带(设备身份由 TCP 连接确定)
  • id = IMEI / IMEI-仓控板-通道 → id 不含 IMEI:柜级省略、单板一段 "01"、仓体级两段 "01-3"
  • 空闲 1 分钟 / 充电 15 秒上报 → status_post 固定每 60 秒一次、每次全量
  • login 应答返回 cell → 返回 spec(柜子规格,如 "0606")与 work_mode
  • pow_fail 带 info → 改为 pow_fail_dc / pow_fail_ac 两条独立指令,无附加字段
  • status_post 的 status HEX 字典 / bat_in 的 bms 块 → 改为 cabinet + boards[].channels[](每通道 18 字符 hex 透传) 与 battery 块
  • bat_in 建单 → bat_in 与订单无关,订单只由 on 建立
  • 内部字段已由 id 改为 _dev_id(供 device-server 路由,下发前摘除)

设计原则

MCU(Air780E)资源有限,TCP 协议遵循以下原则:

  1. 响应扁平化:字段直接放在顶层,不包 data/result 等嵌套层
  2. 字段最小化:需要几个字段传几个,不传空值、默认值
  3. JSON 紧凑:去空格回车,不传多余引号和逗号(可用 serde_json::to_string 紧凑模式)
  4. 响应必达:所有上行报文平台都会回复;但设备回执(带 suc)平台不再回包(防误配对)

通信规则(⚠️ 下列旧描述已废弃,见节首说明)

  • TCP 长连接,无需 PING 包 ✅(保活由 status_post 承担)
  • 空闲 1 分钟上报,充电 15 秒上报 ❌ → 固定每 60 秒全量
  • 超时 5000ms ❌ → 每个交互 60 秒超时,超时作废、迟到应答丢弃、不重试
  • JSON 去空格回车省流量,LF 处理粘包 ✅
  • dev_id:设备上报时使用 ❌ → 仅 auth_str / login 携带
  • id:IMEI-仓控板-通道 / IMEI ❌ → 不含 IMEI:柜级省略 / 单板 "01" / 仓体级 "01-3"
  • msg_id:两个方向各自独立计数,都每连接从 1 起自增,重连归零;判"应答 vs 指令"看有无 suc

签名机制

  • 设备首次连接调 auth_str 接口获取8位安全码(仅一次)
  • sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)
  • 时间戳秒级,平台校验±60秒

指令清单

⛔ 以下整段(含本表与其后的报文示例、cell / pow_fail / bms 字段说明)均为废弃的历史内容,请勿据此实现。 现行清单与报文以 柜控板固件对接文档.md §4 / §5 为准。act 共 13 个: auth_str / login / status_post / pow_fail_dc / pow_fail_ac / bat_in / bat_out / on / off / open / pow_on / pow_off / reset (已无 pow_fail,拆成 pow_fail_dc / pow_fail_ac)

动作 方向 说明(⚠️ 历史,见上方说明)
login 设备→平台 登录(含 sign+timestamp+iccid);成功应答返回 spec + work_mode(不再是 cell)
auth_str 设备→平台 获取安全码(首次 / 重置后;失败统一回 invalid signature 之外的两条见权威文档)
status_post 设备→平台 每 60 秒全量;结构为 cabinet + boards[].channels[](每通道 18 字符 hex),不再是 status 字典
pow_fail 设备→平台 已废弃 → 改为 pow_fail_dc / pow_fail_ac(无附加字段)
pow_fail_dc / pow_fail_ac 设备→平台 直流停电 / 交流停电(事件上报)
bat_in 设备→平台 电池插入(battery 块:属性 + info 18hex + cells hex);不建单
bat_out 设备→平台 电池拔出(结束订单 user_stop)
on 双向 开始充电(上行两形态:带 suc=回执 / 不带=上报;建单唯一入口)
off 双向 充电停止(结束订单 normal)
open 平台→设备 柜门解锁(柜级:整柜共用一扇门,需人拉开;无"关门"指令)
pow_on / pow_off 平台→设备 接触器合闸 / 分闸(pow_off 后仓控板随之下电 → 上报 pow_fail_dc)
reset 平台→设备 重启整柜(id 省略)或单块仓控板(id="01");先回执再重启

⚠️ 所有设备上报消息平台都会回复 {"suc":1,"msg_id":N} 确认。响应字段扁平化,不加 data 包裹。

全局单位规范

所有数值字段使用整数存储,禁止浮点类型:

字段 单位 精度 说明
voltage mV 1 52000 = 52.000V
current mA 1 10000 = 10.000A
temp K×100 0.01K 29865 = 298.65K = 25.5°C
soc ‰ 0.1% 4500 = 45.00%

服务架构

设备 ─TCP─→ device-server ─LPUSH→ Redis ─BRPOP→ api-server
设备 ←TCP─ device-server ←BRPOP─ Redis ←LPUSH─ api-server

职责分层:

层 职责
device-server TCP连接管理、协议解析(HEX→结构化)、消息转发
api-server 业务逻辑:签名验证、状态持久化、充电事件、告警、指令下发

Redis 队列:

队列 方向 说明
device:reports 设备→平台 device-server 转发设备上报
device:commands 平台→设备 api-server 下发指令/响应

全局单位规范

login(设备→平台)

{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"iccid":"89860000000000000000"}
// 响应:{"suc":1,"msg_id":1,"cell":6}
  • iccid — SIM卡ICCID(变更时写 SIM 变更日志)
  • cell — 仓控板数量(柜子配置属性)

auth_str(设备→平台)

{"act":"auth_str","msg_id":2,"dev_id":"123456789012345"}
// 响应:{"suc":1,"msg_id":2,"auth_str":"abcd1234"}
  • auth_str — 8位随机安全码(仅返回一次,标记后不可重复获取)

status_post(设备→平台)

{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,
 "status":{"00":"0001","01":"000100020003000400050006","02":"000100020003000400050006"}}
// 响应:{"suc":1,"msg_id":10}

status 字段:

  • key: "00"=柜子级, "01"~"N"=仓控板级(N = cell-1)
  • value: 6通道状态拼接(每通道4个HEX字符=2字节)
  • device-server 解析后附加 parsed_status 字段,api-server 直接写入 Redis

HEX 解析(device-server 负责,格式待硬件团队最终确认):

  • 字节1 bit1=充电中, bit2=充满, bit3=故障
  • 当前实现:每通道4字符,取字节1解析

⚠️ HEX 编码格式待硬件团队最终确认,当前为预留实现。


pow_fail(设备→平台)

{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345","info":"0"}
// 响应:{"suc":1,"msg_id":15}
  • info — 停电类型:0=直流停电(12V掉电),1=交流停电(380V掉电)

bat_in / bat_out(设备→平台)

{"act":"bat_in","msg_id":20,"id":"123456789012345-01-01","bms":{
  "soc":4500,"voltage":52000,"current":10000,"temp":29865,
  "battery_id":"BAT001","brand":"星恒","model":"XH-48V","sn":"SN123456"
}}
// 响应:{"suc":1,"msg_id":20}

{"act":"bat_out","msg_id":21,"id":"123456789012345-01-01","bms":{
  "battery_id":"BAT001","soc":4500
}}
// 响应:{"suc":1,"msg_id":21}

BMS 字段(全局整数单位):

字段 单位 精度 说明
soc ‰ 0.1% 4500 = 45.00%
voltage mV 1 52000 = 52.000V
current mA 1 10000 = 10.000A
temp K×100 0.01K 29865 = 298.65K = 25.5°C
battery_id — — 电池唯一标识
brand — — BMS品牌
model — — BMS型号
sn — — BMS序列号
cell_voltages mV 1 单体电芯电压数组

bat_out 只需上传 battery_id、soc(其他数据不变)。


on / off(双向)

// 平台→设备:下发充电
{"act":"on","msg_id":30,"id":"123456789012345-01-01"}
// 设备响应:{"suc":1,"msg_id":30}

// 设备→平台:充电开始确认
{"act":"on","msg_id":30,"id":"123456789012345-01-01"}
// 平台响应:{"suc":1,"msg_id":30}

// 平台→设备:下发停止
{"act":"off","msg_id":31,"id":"123456789012345-01-01"}
// 设备响应:{"suc":1,"msg_id":31}

// 设备→平台:充满自动停止
{"act":"off","msg_id":31,"id":"123456789012345-01-01"}
// 平台响应:{"suc":1,"msg_id":31}

on 必须由平台下发,设备不可主动充电。MCU 保证 off → bat_out 顺序。


open(平台→设备)

{"act":"open","msg_id":40,"id":"123456789012345"}

响应: {"suc":1,"msg_id":40}


pow_on / pow_off(平台→设备)

{"act":"pow_on","msg_id":50,"id":"123456789012345"}
{"act":"pow_off","msg_id":51,"id":"123456789012345"}

响应: {"suc":1,"msg_id":50}


reset(平台→设备)

// 重启整柜
{"act":"reset","msg_id":60,"id":"123456789012345"}
// 重启仓控板
{"act":"reset","msg_id":61,"id":"123456789012345-01"}

响应: {"suc":1,"msg_id":60}

充电结束原因

平台收到 判断原因
off (设备→平台) normal — 正常充满
off (平台→设备) user_stop — 用户手动停止
bat_out normal — 电池拔出
bat_in (通道已有电池) forced — 新电池插入强制结束
超时未响应 timeout — 通信超时

Redis 实时状态存储

层级结构

device:{imei}                              # 设备级 Hash
├─ online = "1"                            # 在线标记(TTL 300秒)
├─ rssi = "85"                             # 信号强度
├─ pow_fail_dc = "0/1"                     # 直流停电
├─ pow_fail_ac = "0/1"                     # 交流停电
└─ device:{imei}:board:{board_idx}         # 板级 Hash
   ├─ status_hex = "00010002..."           # 原始hex状态
└─ device:{imei}:board:{board_idx}:ch:{ch}  # 通道级 Hash(1~6)
       ├─ on = "0/1"                        # 充电中
      ├─ full = "0/1"                      # 充满
      ├─ fault = "0/1"                     # 故障
      ├─ hex = "0001"                      # 原始hex(4字符)
      ├─ battery_id = "BAT001"             # 电池ID(bat_in 写入,bat_out 清除)
      ├─ soc = "4500"                      # 电量 ‰
      ├─ voltage = "52000"                 # 电压 mV
      ├─ current = "10000"                 # 电流 mA
      ├─ temp = "29865"                    # 温度 K×100
      ├─ brand = "星恒"                    # BMS品牌
      ├─ model = "XH-48V"                  # BMS型号
      └─ sn = "SN123456"                   # BMS序列号

HEX 解析由 device-server 负责,api-server 只接收结构化数据写入 Redis。

BMS 字段(全局整数单位)

字段 单位 精度 说明
soc ‰ 0.1% 4500 = 45.00%
voltage mV 1 52000 = 52.000V
current mA 1 10000 = 10.000A
energy Wh 1 累计充电量
temp K×100 0.01K 29865 = 298.65K = 25.5°C
battery_id — — 电池唯一标识
brand — — BMS品牌
model — — BMS型号
sn — — BMS序列号
cell_voltages mV 1 单体电芯电压数组

权限码列表

以 software/api-server/src/db/migrate.rs::seed_permissions 的种子表为准(下表与之对齐,共 31 个权限码)。 ⚠️ 历史上代码里曾误用一个不存在的权限码 device:door:unlock(种子里从来没有它)→ 导致开门对所有角色 403,已修正为 charge:open_door。 ⚠️ 同样地,不存在 system:log 这个权限码(文档曾误写)——操作日志用的是 operation_log:view; 照 system:log 配角色会导致该角色永远看不到操作日志。

默认角色授权(seed_roles):

  • super_admin(总管理员):grant_all_permissions —— 种子表全部 31 个;
  • org_admin(企业管理员):24 个 —— device:view/operate/create/edit、charge:start/stop/open_door、 charge_record:view/export、device_log:view、energy:view/export、user:view/create/edit、 operation_log:view、org:*(4)、project:*(4); 不含 device:delete、device:auth_str:reset、user:delete、role:*;
  • normal_user(操作员):8 个 —— device:view/operate、charge:start/stop/open_door、 charge_record:view、device_log:view、energy:view。
权限码 说明
device:view 查看设备
device:operate 操作设备(开合闸 / 重启 / 开门等柜级控制)
device:create 添加设备
device:edit 编辑设备(含修改充电工作模式)
device:delete 删除设备
device:auth_str:reset 重置安全码(仅总管理员)
charge:start 开始充电
charge:stop 停止充电
charge:open_door 开门(柜门解锁)
charge_record:view 查看充电记录
charge_record:export 导出充电记录(也用于下载中心 4 个接口)
device_log:view 查看设备日志
energy:view 查看能耗
energy:export 导出能耗
user:view / user:create / user:edit / user:delete 用户管理
role:view / role:create / role:edit / role:delete 角色管理
operation_log:view 查看操作日志(也用于问题反馈列表/处理状态 2 个接口)
org:view / org:create / org:edit / org:delete 组织管理
project:view / project:create / project:edit / project:delete 项目管理