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

12 KiB
Raw Blame History

数据模型(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;
逐板逐通道 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_status bit7=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/。