charging-cabinet/docs/tech/数据模型.md
12451 7d2b57d021 docs: 澄清"唯一 TCP 协议文档"并隔离硬件总线协议;删除已完成任务、摘出未结待办
## 起因

用户提问:**"我们不是有一个 tcp 协议权威文档了吗?为啥还有其他 tcp 协议文档"** ——
查证后确认:**权威文档只有一份**,但目录布局让人容易误判,且**我上一轮的搬迁引入了一处断链**。

## 一、查证结果:全仓只有一份 TCP 协议文档

按"是否同时出现 `auth_str`/`status_post`/`bat_in` 等 act 名"全仓扫描,含协议内容的只有:

| 文件 | act 命中 | 性质 |
|---|---:|---|
| `docs/protocol/柜控板固件对接文档.md` | 171 | ⭐ **唯一权威 TCP 协议规格** |
| `docs/tech/API.md` | 14 | HTTP 接口文档,TCP 章节**只有一句指回权威文档的链接** |
| `AGENTS.md` / `数据模型.md` / `小程序用户端.md` | 13 / 8 / 3 | 规则与存储说明,非协议规格 |
| `docs/archive/项目深度分析报告-2026-07-06.md` | 1 | 历史归档 |

⇒ **没有任何第二份 TCP 协议文档。** 用户看到的"另一份"应是下面二者之一(都已处理):

### 1. `docs/protocol/1TN协议标准_*` —— **不是 TCP 协议**

它是**仓控板 ↔ 翌工电源的 RS485 总线协议**(另一条物理链路)。与 TCP 权威文档同放
`protocol/`,名字又像"协议标准",极易被当成第二份平台协议。
**已移到新目录 `docs/hardware/`**(硬件侧总线协议),并在两处都写明"**不是**平台 TCP 协议"。

**结果:`docs/protocol/` 下有且仅有一个文件** —— 一眼就能确认"只有一份"。

### 2. `tasks/001-TCP协议对齐与安全修复.md`(66 KB)—— 是任务记录,不是协议文档

文件名含"TCP协议",且 66 KB。它已被完整处理(见下节)。

## 二、删除已完成的任务文件,并摘出真正的未结待办

`tasks/` **已 gitignore(`.gitignore:27`),是本地草稿区、从不入库**。

`tasks/001` 的 A~AH **全部 ✅ 已完成**,但其**状态头写着"待排期(未开始编码)"** —— 是错的。
按「已完成的任务文件及时清理」,**已删除**;删前把**唯一两项未结**摘成
`tasks/002-未结待办-协议待确认项与长函数重构.md`:

1. **`energy` 数据源待硬件答复**(协议待确认第 13 项,阻塞中)+ 关联的第 16 项;
2. **长函数重构**(已授权未开工)。

> ⚠️ **顺带纠正 001 的两处数字**(实测后才发现,**不能沿用**):
> - 001 记"23 个函数超 80 行"—— **当前实测只有 8 个**(本会话多轮重构已拆掉大部分);
> - 001 记 `migrate.rs::run` 已从 280 拆到 **46 行** —— **拆分本身没被回退**
>   (`SCHEMA_SQL` 确在 L20、`run` 内 0 个 `CREATE TABLE`),但后续每轮迁移改动都往 `run` 里追加
>   (`LEGACY_COLUMN_TARGETS` 对齐、`ensure_active_order_unique`、列注释对齐……),
>   **它又长回 174 行,重新成为全仓最长函数** —— 这是一次**重构回归**,已列为第一优先。
> - 实测脚本随 002 一起留在 `tasks/`(该目录不入库,避免下次重新发明)。

## 三、修复我上一轮搬迁引入的断链(1 处真缺陷 + 1 处机械替换误伤)

搬迁脚本只覆盖了 `docs/<name>` 与 `../<name>` 两种形态,**没覆盖三种情况**,已全部修好并加了体检:

| 位置 | 问题 | 修复 |
|---|---|---|
| `docs/tech/API.md` | 同级相对链接 `](柜控板固件对接文档.md)` 因文件被移走而断链 | → `](../protocol/柜控板固件对接文档.md)` |
| `docs/tech/数据模型.md` | 我的替换把 **markdown 链接目标** `](docs/API.md)` 也换成了 `docs/tech/API.md` ⇒ 解析成 `docs/tech/docs/tech/API.md` | 显示文字保留可读路径,**链接目标改回 `API.md`** |
| `docs/archive/审查报告…md` | 指向当时已删除的 `./tcp-protocol.md`(历史事实) | 归档**保留原文**,顶部加**归档横幅 + 旧名→现名映射表** |

**新增体检方法并实测能发现问题**:遍历 `docs/**/*.md` 的所有相对链接、逐个还原路径判存在性。
修完后复跑 → 只剩归档那一处(按设计保留),已用横幅给出映射。

> 📌 教训(已写进 `docs/README.md` 维护约定):**移动文档后必须做链接体检**,
> 不能只做字符串替换 —— 同级相对链接、以及"写在链接目标里的仓库相对路径"都会被漏掉。

## 四、文档地图同步更新

`docs/README.md` 新增:
- 顶部**「关于到底有几份 TCP 协议文档」**专节:直接回答,并列出"三份内部件何时被删"的历史;
- `hardware/` 目录说明;
- `protocol/` 明确标注 **⛔ 不要在这里放第二份协议文档**(要补充就改那一份并升版本号)。

`AGENTS.md` 项目结构同步加 `hardware/` 一行。
2026-09-20 04:55:28 +08:00

172 lines
12 KiB
Markdown
Raw 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` 各 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](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/`。