charging-cabinet/docs/tech/数据模型.md
12451 f3e733eaec docs: 电表读数落库的文档同步(AGENTS.md/数据模型 21->22 张表;tasks/002 标记已实现)
- AGENTS.md 与 docs/tech/数据模型.md:21 -> 22 张表,补 cabinet_meter_readings 说明(抄表留痕:只在读数变化时追加,cabinets.meter_wh 冗余最新值)
- tasks/002:电表项由「待确认是否实现」改为「已实现」并列出实测证据(4 次同值只留 1 行、改值新增 1 行)
- 生产已发布并核验:22 张表、结构漂移 0、0 ERROR
2026-09-20 05:55:37 +08:00

172 lines
12 KiB
Markdown
Raw Permalink 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.

# 数据模型(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 维护,供结单取电量)
```
> ⚠️ **关键事实(旧文档写错了,逐条纠正):**
> 1. **板级 hash `device:{imei}:board:{板号}` 根本不存在** —— 现协议板级没有状态字节,
> 写入侧**只写通道级 key**。`/api/cabinets/realtime-status` 响应里那个 `boards[].status_hex`
> 是**接口现拼**的(各通道 `batt_status + pow_status` 按通道号升序拼接),**不是一个 Redis 键**。
> 生产 Redis 里若还能搜到 `device:*:board:N` 这种 hash(内容只有 `status_hex`),那是**旧版本残留**。
> 2. **通道号是 1 基(1–8),不是 0 基**;老数据里出现过 `...:ch:0`,也是旧版本残留。
> 板号同样从 1 开始,量程 1–15。
> 3. **通道级字段是 `batt_status` / `pow_status` / `soc` / `voltage` / `current` / `temp`**,
> **没有** `on` / `full` / `fault` / `hex` 这四个字段(那是旧格式;`on/full/fault/hex` 是接口
> 由 `batt_status`/`pow_status` **派生出来的响应字段**,不是存储字段)。
> 4. **板号和通道号十进制不补零**(`board:1:ch:3`,不是 `board:01:ch:03`)。
> 5. 设备级 key 的**字段可能是残缺的**(如只有 `online`+`rssi`):`cabinet_status1/2` 只在
> `status_post` 带合法 `cabinet.status1/2` 时才写;**读不到就是"无数据",绝不落 `"00"`**。
> 6. `device:{imei}` 的 **TTL 就是在线判据**(300 秒内无任意上行即判离线),
> 所以**任何写 `device:{imei}` 的代码都必须跟一条 `EXPIRE 300`**。
> 7. 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`;<br>逐板逐通道 `device:{imei}:board:{b}:ch:{c}` 的 6 个字段;<br>`cabinet.status1` bit0=0 时**顺带把 `pow_fail_dc`/`pow_fail_ac` 清 0**(供电恢复);<br>并累加 `...: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_status` bit7=1、bit0/bit1=0)这类真实状态。
**API 接口(接口总表以 [docs/tech/API.md](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 = 键不存在(等价于只返回存在的键)。<br>✅ **2026-09 已修**:旧实现用 `KEYS device:{imei}:board:*:ch:*`,在**与既有充电站平台共用**的 Redis 实例上会阻塞单线程(实测每次请求 `cmdstat_keys` +1),已彻底移除 |
> ⛔ **本项目禁止在任何请求路径上用 `KEYS`** —— Redis 实例是共用的,`KEYS` 会阻塞单线程遍历整个
> keyspace。要枚举就用「按 DB 拓扑拼 key」或 `SCAN` 游标。查是否有人用了:
> `grep -rn '"KEYS"' software/*/src/`。