charging-cabinet/docs/tech/数据模型.md

172 lines
12 KiB
Markdown
Raw Normal View History

docs: 文档分类整理 —— AGENTS.md 只留判据与链接,详细篇按用途归入 docs/ 子目录 ## 为什么要做 `AGENTS.md` 曾达 **90,370 B**,而工作区指令预算是 **65,536 B** —— 系统按**保留头部**截断, 于是**尾部约 25 KB 永远不会被加载**:组织架构、数据模型、TCP 协议、数据单位规范、 微信小程序用户端**全部处于"写了但读不到"的状态**。手册实际上部分失效。 ## 一、AGENTS.md 瘦身:90,370 → 60,227 B(余量 5,309 B,尾部不再被截断) 三个**纯参考型大段**整段搬迁到 `docs/tech/`(**内容逐字保留,未删改**), 原位置换成「判据 + 链接」: | 原 AGENTS.md 章节 | 大小 | 现位置 | |---|---|---| | `## 部署与维护` | 17,611 B | `docs/tech/部署手册.md` | | `## 数据模型` | 12,100 B | `docs/tech/数据模型.md` | | `## 微信小程序用户端` | 10,262 B | `docs/tech/小程序用户端.md` | 替换后的章节**只留改动前必须先知道的硬约束**(例如:产物名与测试环境落地名不一致、 ETXTBSY 的 `.new`→`stop`→`mv`→`start` 流程、`stream{}` 不可重加、发布顺序 device 先 api 后、 建表顺序即依赖顺序且写错报 MySQL 1824、时间列必须 DATETIME、`cabinets.status` 是配置态不表示在线……), 其余指向详细篇。**判据留在常驻加载的 AGENTS.md,逐项参考移出**。 ## 二、docs/ 分类(原 21 个文件平铺 → 五个用途目录) ``` docs/ ├── README.md 新增:文档地图(分类/权威性/旧名→现名映射/维护约定) ├── protocol/ 设备协议(冻结,对硬件交付)★唯一权威 │ ├── 柜控板固件对接文档.md │ └── 1TN协议标准_*.md / *.pdf ├── tech/ 平台技术文档(开发/运维必读) │ ├── API.md · 数据模型.md · 部署手册.md · 小程序用户端.md ├── contract/ 合同·规格·验收标准(商务依据,不描述实现) ├── compound-notes/ 单点经验笔记(原有) └── archive/ ⛔ 历史留痕(只读,不代表现状,不可作依据) ├── 开发文档.md(早于现行实现) └── 审查报告/深度分析报告-2026-07-06 ``` - 19 个文件用 `git mv` 迁移(保留历史),3 个未跟踪文件用普通移动; - **内容零改动**(唯一例外是引用替换),已核验 `docs/tech/API.md` 的 `git diff` 为空。 ## 三、全仓引用同步:40 处 / 10 个文件 路径不只出现在文档里,还散落在**源码注释**(`db/migrate.rs`、`routes/cabinets.rs`、 `workers/report_worker.rs`、`tcp/connection.rs`、`h5-uniapp/utils/format.ts`)、 `test/tcp_e2e_smoke.py`、`tasks/001-*.md`、`README.md`、`AGENTS.md` 中,全部同步。 **并已实测校验命令能失败**:往 `docs/tech/API.md` 植入 `<!-- probe: docs/API.md -->` → 命中;删除 → 无输出。 > ⚠️ 过程中踩了一个坑并已修:这些 `.md` 多为 **CRLF**,我第一次用 `\n` 正则删除时 > **没匹配上 `\r\n`**,以为删干净了其实残留了一行 —— 靠 `git diff` 非空才发现。 > 已把「CRLF 必须按 `\r?\n` 处理」写进 `docs/README.md` 的维护约定。 ## 四、顺带查实的失效引用(归档保留原文,不改写历史) - `docs/tcp-protocol.md`:仅出现在 2026-07-06 的两份历史报告里(当时的事实); - `docs/柜控板-平台TCP接口文档.md`:仅出现在协议文档 **v1.1 变更记录**里 (记录"已删除该内部重复文档"这件事),**不是活链接**,无需修复。 两者已在 `docs/README.md` 的「旧名 → 现名映射」表中给出对应关系。
2026-09-20 04:42:51 +08:00
# 数据模型(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` 各 21 张表**(依据:
`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](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 = 键不存在(等价于只返回存在的键)。<br>✅ **2026-09 已修**:旧实现用 `KEYS device:{imei}:board:*:ch:*`,在**与既有充电站平台共用**的 Redis 实例上会阻塞单线程(实测每次请求 `cmdstat_keys` +1),已彻底移除 |
> ⛔ **本项目禁止在任何请求路径上用 `KEYS`** —— Redis 实例是共用的,`KEYS` 会阻塞单线程遍历整个
> keyspace。要枚举就用「按 DB 拓扑拼 key」或 `SCAN` 游标。查是否有人用了:
> `grep -rn '"KEYS"' software/*/src/`。