- AGENTS.md 与 docs/tech/数据模型.md:21 -> 22 张表,补 cabinet_meter_readings 说明(抄表留痕:只在读数变化时追加,cabinets.meter_wh 冗余最新值) - tasks/002:电表项由「待确认是否实现」改为「已实现」并列出实测证据(4 次同值只留 1 行、改值新增 1 行) - 生产已发布并核验:22 张表、结构漂移 0、0 ERROR
12 KiB
数据模型(AGENTS.md 的详细参考篇)
本文件由
AGENTS.md的「数据模型」整段搬迁而来(内容逐字保留,未删改)。 改任何表结构前必须读本文件:建表顺序即依赖顺序(写错 MySQL 报 1824)、 外键/唯一键清单、以及各表逐列语义都在这里。 两条硬规则已留在AGENTS.md(时间列必须DATETIME、CREATE TABLE IF NOT EXISTS不改已存在列 ⇒ 必须同步LEGACY_COLUMN_TARGETS)。
数据模型
核心关系
organization(组织/企业)
├── project(项目/放置地点)
│ └── cabinet(充电柜)
│ ├── cabin_board(仓控板×N) board_id 1–15
│ │ └── compartment(仓体×6) channel_id 1–8
│ ├── charge_order(充电订单,进行中/已结算;active_flag 唯一键防并发)
│ ├── charge_record(充电记录,结算后留痕)
│ ├── energy_stat(能耗统计,按 柜×日 唯一)
│ └── device_log(设备日志)
├── user(用户) ──┬── user_roles ── role ── role_permissions ── permission
│ ├── user_scopes(数据范围,项目级生效)
│ └── operation_log(操作日志) / user_feedback(问题反馈) / download_tasks(下载中心)
└── abstract_binding(平台抽象ID 10-xxxxxxxx ↔ cabinet 一对一)
battery(电池台账,按 battery_id 唯一) ── sim_change_log(换 SIM 记录,按 imei)
外键与唯一键要点(改动前必读)
compartments的父是cabin_boards,cabin_boards的父是cabinets,cabinets的父是projects; 建表语句顺序即依赖顺序(migrate.rs::SCHEMA_SQL,被引用表必须先建,否则 MySQL 报 1824)。charge_orders/charge_records同时外键到compartments与cabinets。energy_stats有UNIQUE(cabinet_id, stat_date);abstract_bindings/batteries/cabinets.imei(唯一) 同理。
数据库表
实测:测试库 pms_dev 与生产库 pms 各 22 张表(依据:
SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=...,两库表名完全一致)。
| 表名 | 说明 |
|---|---|
organizations |
组织/企业(parent_id 支持父组织,NULL=顶级) |
projects |
项目/放置地点(隶属组织) |
cabinets |
充电柜(imei 唯一、abstract_id、auth_str+auth_str_issued、cell、work_mode、status、经纬度、address) |
cabin_boards |
仓控板(board_id 1–15) |
compartments |
仓体/通道(channel_id 1–8;status 只表示占用 0 空闲/1 充电中,充满与故障不落库) |
batteries |
电池台账(battery_id 唯一、SOC‰、厂家/型号/材料代码原值、额定电压容量、状态) |
charge_orders |
充电订单(进行中/已结算;reason normal/user_stop/forced/timeout;active_flag 唯一键防并发建单) |
charge_records |
充电记录(结算后留痕;energy 单位 Wh、max_temperature K×100) |
device_logs |
设备日志(log_type 1 状态上报 / 2 故障 / 3 告警 / 4 停电) |
energy_stats |
能耗统计(按 cabinet_id + stat_date 唯一,total_energy 单位 Wh) |
operation_logs |
操作日志(按 user_id 归属组织;不叠加项目级数据范围) |
users |
用户(phone 唯一、argon2 密码、role 0/1/2、organization_id) |
roles |
角色(内置 super_admin / org_admin / normal_user) |
permissions |
权限码字典(31 条,见 migrate.rs::seed_permissions) |
role_permissions |
角色↔权限 关联(复合主键) |
user_roles |
用户↔角色 关联(复合主键;为空时按 users.role 回退内置角色权限,见 auth.rs::load_user_permissions) |
user_scopes |
数据范围(scope_type 1 组织 / 2 项目 / 3 设备;只有 1、2 参与鉴权) |
download_tasks |
下载中心/异步导出任务(task_type、status 0排队~3失败、file_path) |
abstract_bindings |
平台抽象ID ↔ 柜子 绑定(abstract_id / cabinet_id 均唯一) |
sim_change_logs |
SIM 卡(ICCID)变更记录(login 时检测到变更即写) |
user_feedback |
用户问题反馈(内容/联系方式/处理状态) |
表清单的权威来源是
software/api-server/src/db/migrate.rs::SCHEMA_SQL(代码建表 DDL) 与库实测两处,两者必须一致;不一致用python tools/db_schema_diff.py --vs-ddl查。
Redis 实时状态存储
权威依据:
software/api-server/src/workers/report_worker.rs的模块头注释 +write_device_state/write_channel_state/handle_pow_fail/save_channel_battery(写入侧),workers/energy.rs::order_energy_key(累加器),device-server/src/tcp/server.rs::update_device_route(路由)。 下表是逐字段核对代码后的实际写入契约,不是设计稿。
层级结构(实测:测试库 db6 的真实 key/hash 与下表完全一致):
device:{imei} # 设备级 Hash(TTL 300 秒,写它的每一条路径都会 EXPIRE 300)
├─ online = "1" # 在线标记(login 成功 / status_post 均会置)
├─ rssi = "85" # 信号强度(协议口径 0–99 等级,非 dBm)
├─ temp = "29800" # 柜温 K×100(status_post 的 cabinet.temp)
├─ meter = "123456" # 电表读数(status_post 的 cabinet.meter)
├─ cabinet_status1 = "01" # 柜级状态位 byte0(2 位 hex,**取不到不写**)
├─ cabinet_status2 = "00" # 柜级状态位 byte1(2 位 hex,**取不到不写**)
├─ pow_fail_dc = "0/1" # 直流停电(接触器分闸)
├─ pow_fail_ac = "0/1" # 交流停电(柜控板自身断电)
└─ node_id = "node-1" # 该设备 TCP 连接所在节点(device-server 写,用于多节点路由)
device:{imei}:board:{板号}:ch:{通道号} # 通道级 Hash(**唯一的状态载体,无 TTL**)
├─ batt_status = "82" # byte0 电池状态(2 位小写 hex,位定义见协议 §3.8)
├─ pow_status = "82" # byte1 电源状态(2 位小写 hex,与电池状态独立)
├─ soc = "850" # SOC ‰(硬件 1% → ×10)
├─ voltage = "54000" # 电压 mV(硬件 0.01V → ×10)
├─ current = "8000" # 电流 mA(硬件 0.01A → ×10)
└─ temp = "29800" # 电芯最高温 K×100(硬件 1K → ×100)
device:{imei}:board:{板号}:ch:{通道号}:bat # 通道电池台账(内部键,TTL 48h,bat_in 登记)
├─ battery_id / soc / info / brand_code / model_code / material_code / cell_count
└─ (rated_voltage / rated_capacity)
device:{imei}:board:{板号}:ch:{通道号}:order # 通道订单电量累加器(内部键,Lua 原子累加)
├─ mwh / maxtemp / ts / nobat
└─ (由 workers/energy.rs 维护,供结单取电量)
⚠️ 关键事实(旧文档写错了,逐条纠正):
- 板级 hash
device:{imei}:board:{板号}根本不存在 —— 现协议板级没有状态字节, 写入侧只写通道级 key。/api/cabinets/realtime-status响应里那个boards[].status_hex是接口现拼的(各通道batt_status + pow_status按通道号升序拼接),不是一个 Redis 键。 生产 Redis 里若还能搜到device:*:board:N这种 hash(内容只有status_hex),那是旧版本残留。- 通道号是 1 基(1–8),不是 0 基;老数据里出现过
...:ch:0,也是旧版本残留。 板号同样从 1 开始,量程 1–15。- 通道级字段是
batt_status/pow_status/soc/voltage/current/temp, 没有on/full/fault/hex这四个字段(那是旧格式;on/full/fault/hex是接口 由batt_status/pow_status派生出来的响应字段,不是存储字段)。- 板号和通道号十进制不补零(
board:1:ch:3,不是board:01:ch:03)。- 设备级 key 的字段可能是残缺的(如只有
online+rssi):cabinet_status1/2只在status_post带合法cabinet.status1/2时才写;读不到就是"无数据",绝不落"00"。device:{imei}的 TTL 就是在线判据(300 秒内无任意上行即判离线), 所以任何写device:{imei}的代码都必须跟一条EXPIRE 300。- hex 编码格式已定稿(协议 §3.8 的位定义表),不再有"待硬件团队确认"的悬置项。
写入时机:
| 事件 | 写入内容 |
|---|---|
login(验签通过) |
device:{imei}:online=1 + EXPIRE 300;另写 node_id(device-server 侧) |
status_post |
device:{imei}:online/rssi/temp/meter(+cabinet_status1/2) + EXPIRE 300;逐板逐通道 device:{imei}:board:{b}:ch:{c} 的 6 个字段;cabinet.status1 bit0=0 时顺带把 pow_fail_dc/pow_fail_ac 清 0(供电恢复);并累加 ...:order 的电量 |
pow_fail_dc |
device:{imei}.pow_fail_dc = 1 |
pow_fail_ac |
device:{imei}.pow_fail_ac = 1 |
bat_in |
device:{imei}:board:{b}:ch:{c}:bat(电池台账,TTL 48h) |
| 任意上行报文 | device-server 刷新 device:{imei} 的 TTL/node_id(路由用) |
⛔ 没有
pow_fail这条 act:停电是两条独立指令pow_fail_dc(直流,接触器分闸→仓控板下电) 与pow_fail_ac(交流,柜控板自身断电),各写同名字段(协议 §5.4 / §10 第 11 条)。report_worker.rs:237的"pow_fail_dc" | "pow_fail_ac" => handle_pow_fail(...)是唯一入口。
通道状态位(batt_status / pow_status)—— 按协议 §3.8 位定义,不按"五态枚举":
batt_status(byte0):bit0 充满|bit1 正在充电|bit2–3 保留(不展示)|bit4 过欠压|bit5 过流|
bit6 热失控|bit7 有电池(决定"空仓/在位")。
pow_status(byte1):bit0 电源故障|bit1–2 电源状态位|bit3–6 保留(不展示)|bit7 电源有效。
全仓唯一权威推导规则(routes/cabinet_tree.rs::derive_channel_flags,前端
web/src/utils/deviceStatus.ts 与 h5-uniapp/src/utils/deviceStatus.ts 同源):
展示状态 = fault > full > charging > idle
fault = batt_status bit4|bit5|bit6 或 pow_status bit0
full = batt_status bit0
charging = batt_status bit1
无数据(batt_status / pow_status 都取不到)= has_data:false
→ 列表计数按 0 计、详情保留 DB 占用值,**绝不猜测**
旧文档那张"空闲/插入/充电中/充满/故障"五态表已废弃:它既不是协议口径, 也无法表达"电池在位但未充电"(
batt_statusbit7=1、bit0/bit1=0)这类真实状态。
API 接口(接口总表以 docs/tech/API.md 为准,这里只列与 Redis 存储强相关的两个):
| 接口 | 说明 | Redis 查询方式 |
|---|---|---|
GET /api/cabinets/filter |
设备列表(设备级状态 + 充满/故障计数) | ① Pipeline HGET 每柜 6 个设备级字段(online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2,1 次往返);② 通道计数:按 DB 的板号/通道号拼 key(fetch_channel_keys,SQL 取拓扑)+ pipeline HMGET(batt_status, pow_status) —— 零 KEYS 扫描 |
GET /api/cabinets/realtime-status?imei=xxx |
单设备详情(板级+通道级) | HMGET 取设备级 4 字段;板/通道:按 DB 拓扑(cabinets→cabin_boards→compartments)拼候选 key + 一次 pipeline HGETALL,空 map = 键不存在(等价于只返回存在的键)。✅ 2026-09 已修:旧实现用 KEYS device:{imei}:board:*:ch:*,在与既有充电站平台共用的 Redis 实例上会阻塞单线程(实测每次请求 cmdstat_keys +1),已彻底移除 |
⛔ 本项目禁止在任何请求路径上用
KEYS—— Redis 实例是共用的,KEYS会阻塞单线程遍历整个 keyspace。要枚举就用「按 DB 拓扑拼 key」或SCAN游标。查是否有人用了:grep -rn '"KEYS"' software/*/src/。