653 lines
28 KiB
Rust
Raw Normal View History

//! 数据库迁移 — 自动建表
use sqlx::MySqlPool;
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
/// 全量建表语句(幂等:全部 `CREATE TABLE IF NOT EXISTS`)。
/// 抽成常量是为了让 [`run`] 保持可读(此前 280 行的函数里有 233 行是这段 SQL)。
const SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS organizations (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL COMMENT '组织名称',
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
parent_id BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS projects (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
organization_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL COMMENT '项目名称',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabinets (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
project_id BIGINT,
abstract_id VARCHAR(20) NULL UNIQUE COMMENT '平台抽象ID,如10-00000001',
imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI',
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
iccid VARCHAR(20) COMMENT 'SIM卡ICCID',
auth_str VARCHAR(8) COMMENT '签名安全码',
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
auth_str_issued BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发',
cell TINYINT DEFAULT 6 COMMENT '仓控板数量',
work_mode TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式',
name VARCHAR(100) COMMENT '柜子名称',
status TINYINT DEFAULT 0 COMMENT '0离线 1在线',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
CREATE TABLE IF NOT EXISTS sim_change_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
imei VARCHAR(15) NOT NULL COMMENT '设备IMEI',
old_iccid VARCHAR(20) COMMENT '旧ICCID',
new_iccid VARCHAR(20) NOT NULL COMMENT '新ICCID',
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '变更时间',
INDEX idx_imei (imei)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS batteries (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
battery_id VARCHAR(50) NOT NULL UNIQUE COMMENT '电池唯一标识',
soc INT DEFAULT 0 COMMENT '电量(‰)',
brand VARCHAR(50) COMMENT '品牌(由 brand_code 映射出的名称,代码表未定前为空)',
model VARCHAR(50) COMMENT '型号(由 model_code 映射出的名称,代码表未定前为空)',
sn VARCHAR(100) COMMENT '序列号',
-- 以下为协议 §5.5「电池属性」原值(硬件代码,平台侧负责映射,见交付文档 §10 定稿第 7 条)
brand_code VARCHAR(20) COMMENT '厂家代码(原值透传)',
model_code VARCHAR(20) COMMENT '电池型号代码(原值透传)',
material_code VARCHAR(20) COMMENT '电芯材料代码(原值透传)',
cell_count INT COMMENT '电芯节数',
rated_voltage BIGINT COMMENT '额定电压(mV)',
rated_capacity BIGINT COMMENT '额定容量(mAh)',
status TINYINT DEFAULT 0 COMMENT '0空闲 1使用中 2故障 3报废',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS charge_orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
compartment_id BIGINT NOT NULL COMMENT '仓体ID',
cabinet_id BIGINT NOT NULL COMMENT '柜子ID',
battery_id VARCHAR(50) NOT NULL COMMENT '电池ID',
start_time TIMESTAMP NULL COMMENT '充电开始时间',
end_time TIMESTAMP NULL COMMENT '充电结束时间',
start_soc INT COMMENT '起始电量(‰)',
end_soc INT COMMENT '结束电量(‰)',
energy INT DEFAULT 0 COMMENT '充电量(Wh)',
reason VARCHAR(20) COMMENT '结束原因: normal/user_stop/forced/timeout',
status TINYINT DEFAULT 0 COMMENT '0充电中 1已完成 2异常',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (compartment_id) REFERENCES compartments(id),
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
INDEX idx_battery (battery_id),
INDEX idx_compartment (compartment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS cabin_boards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
board_id TINYINT NOT NULL COMMENT '仓控板ID 1-6',
status TINYINT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS compartments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabin_board_id BIGINT NOT NULL,
channel_id TINYINT NOT NULL COMMENT '通道ID 1-6',
status TINYINT DEFAULT 0 COMMENT '0空闲 1充电中 2故障 3禁用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (cabin_board_id) REFERENCES cabin_boards(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS users (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
phone VARCHAR(11) NOT NULL UNIQUE COMMENT '手机号',
name VARCHAR(50) COMMENT '姓名',
password VARCHAR(255) NOT NULL,
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
role TINYINT DEFAULT 0 COMMENT '0操作员 1企业管理员 2总管理员',
organization_id BIGINT,
status TINYINT DEFAULT 1 COMMENT '0禁用 1启用',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (organization_id) REFERENCES organizations(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS charge_records (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
compartment_id BIGINT NOT NULL,
cabinet_id BIGINT NOT NULL,
start_time TIMESTAMP NULL COMMENT '充电开始时间',
end_time TIMESTAMP NULL COMMENT '充电结束时间',
energy BIGINT NOT NULL DEFAULT 0 COMMENT '充电量(Wh)',
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)',
status TINYINT DEFAULT 0 COMMENT '0正常 1异常 2手动停止',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (compartment_id) REFERENCES compartments(id),
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS device_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
log_type TINYINT NOT NULL COMMENT '1状态上报 2故障 3告警 4停电',
content TEXT COMMENT '日志内容JSON',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS energy_stats (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
cabinet_id BIGINT NOT NULL,
stat_date DATE NOT NULL,
total_energy BIGINT NOT NULL DEFAULT 0 COMMENT '总能耗(Wh)',
charge_count INT DEFAULT 0 COMMENT '充电次数',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id),
UNIQUE KEY uk_cabinet_date (cabinet_id, stat_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS operation_logs (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
action VARCHAR(100) NOT NULL COMMENT '操作动作',
target_type VARCHAR(50) COMMENT '操作对象类型',
target_id BIGINT COMMENT '操作对象ID',
detail TEXT COMMENT '操作详情',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS roles (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE COMMENT '角色名称',
description VARCHAR(200) COMMENT '角色描述',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS permissions (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(50) NOT NULL UNIQUE COMMENT '权限码,如device:view',
name VARCHAR(100) NOT NULL COMMENT '权限名称',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS role_permissions (
role_id BIGINT NOT NULL,
permission_id BIGINT NOT NULL,
PRIMARY KEY (role_id, permission_id),
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
PRIMARY KEY (user_id, role_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS user_scopes (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
scope_type TINYINT NOT NULL COMMENT '1组织 2项目 3设备',
scope_id BIGINT NOT NULL COMMENT '对应组织/项目/设备ID',
permission_type TINYINT NOT NULL COMMENT '1查看 2操作',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS download_tasks (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
task_type VARCHAR(50) NOT NULL COMMENT 'charge_records/energy_stats/device_logs',
status TINYINT DEFAULT 0 COMMENT '0排队 1处理中 2完成 3失败',
file_path VARCHAR(500) COMMENT '文件路径',
file_name VARCHAR(200) COMMENT '文件名',
progress INT DEFAULT 0 COMMENT '进度百分比',
error_message TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP NULL COMMENT '完成时间',
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS abstract_bindings (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
abstract_id VARCHAR(20) NOT NULL UNIQUE COMMENT '平台抽象ID,如10-00000001',
cabinet_id BIGINT NOT NULL UNIQUE COMMENT '柜子ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (cabinet_id) REFERENCES cabinets(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
CREATE TABLE IF NOT EXISTS user_feedback (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL COMMENT '提交用户ID',
content TEXT NOT NULL COMMENT '反馈内容',
contact VARCHAR(200) COMMENT '联系方式',
status TINYINT DEFAULT 0 COMMENT '0待处理 1已处理',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
"#;
/// 执行数据库迁移,创建所有必要的数据表
pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 逐条执行全量建表语句,避免多语句执行问题
for statement in SCHEMA_SQL.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
sqlx::query(statement).execute(pool).await?;
}
// 兼容已有数据库:将 abstract_id 改为可空
let _ = sqlx::query(r#"ALTER TABLE cabinets MODIFY abstract_id VARCHAR(20) NULL"#)
.execute(pool).await;
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
// 更新 charge_records.max_temperature 注释(0.1°C → K×100)
// 只在注释确实过时(老库)时才 ALTER:避免每次启动都对整表做一次 MODIFY
ensure_column_comment(
pool,
"charge_records",
"max_temperature",
"K×100",
r#"ALTER TABLE charge_records MODIFY max_temperature BIGINT NOT NULL DEFAULT 0 COMMENT '最高温度(K×100)'"#,
)
.await?;
// 更新 charge_orders.reason 注释:新增 timeout
// (订单超过 10 小时由 order_timeout 定时任务自动结束,落该枚举值)
ensure_column_comment(
pool,
"charge_orders",
"reason",
"timeout",
r#"ALTER TABLE charge_orders MODIFY COLUMN reason VARCHAR(20) NULL COMMENT '结束原因: normal/user_stop/forced/timeout'"#,
)
.await?;
// 创建关键索引(IF NOT EXISTS 避免重复)
create_indexes(pool).await?;
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
// 兼容已有数据库:新增字段(幂等,列已存在则跳过)
add_columns_if_not_exists(pool).await?;
// 初始化默认权限码(INSERT IGNORE 避免重复插入)
seed_permissions(pool).await?;
// 初始化默认角色
seed_roles(pool).await?;
tracing::info!("数据库迁移完成");
Ok(())
}
/// 创建关键查询索引(幂等,已存在则跳过)
async fn create_indexes(pool: &MySqlPool) -> Result<(), sqlx::Error> {
/// 辅助:检查索引是否存在,不存在则创建
async fn ensure_index(
pool: &MySqlPool,
table: &str,
index: &str,
columns: &str,
) -> Result<(), sqlx::Error> {
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM information_schema.statistics \
WHERE table_schema = DATABASE() AND table_name = ? AND index_name = ?",
)
.bind(table)
.bind(index)
.fetch_one(pool)
.await?;
if exists.0 == 0 {
let ddl = format!("CREATE INDEX {} ON {}({})", index, table, columns);
sqlx::query(&ddl).execute(pool).await?;
}
Ok(())
}
ensure_index(pool, "cabinets", "idx_cabinets_project", "project_id").await?;
ensure_index(pool, "cabin_boards", "idx_cabin_boards_cabinet", "cabinet_id").await?;
ensure_index(pool, "compartments", "idx_compartments_board", "cabin_board_id").await?;
ensure_index(pool, "charge_records", "idx_charge_records_cabinet", "cabinet_id").await?;
ensure_index(pool, "device_logs", "idx_device_logs_cabinet", "cabinet_id").await?;
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
// 订单超时兜底扫描(status = 0 AND start_time < ...)走该联合索引,避免全表扫描
ensure_index(pool, "charge_orders", "idx_charge_orders_status_start", "status, start_time").await?;
// device_logs 保留策略按 created_at 分批删除,必须有该列索引,否则每次清理全表扫描
ensure_index(pool, "device_logs", "idx_device_logs_created", "created_at").await?;
tracing::info!("数据库索引创建完成");
Ok(())
}
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
/// 列级幂等迁移:列不存在时才 ALTER TABLE 补列
///
/// 为什么需要:`CREATE TABLE IF NOT EXISTS` 只保证「表存在」,
/// 对**已存在**的表永远不会补后加的列,导致线上 schema 漂移。
/// 安全性:表名/列名/DDL 全部来自本文件内的白名单常量,不含任何外部输入拼接。
async fn ensure_column(
pool: &MySqlPool,
table: &str,
column: &str,
ddl: &str,
) -> Result<(), sqlx::Error> {
let exists: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
)
.bind(table)
.bind(column)
.fetch_one(pool)
.await?;
if exists.0 == 0 {
let alter = format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, ddl);
sqlx::query(&alter).execute(pool).await?;
tracing::info!("[migrate] 新增列 {}.{}", table, column);
}
Ok(())
}
/// 列注释幂等更新:仅当现有注释不包含 `marker` 时才 ALTER
///
/// 为什么:老库的列注释不会随 CREATE TABLE IF NOT EXISTS 更新;
/// 而 MODIFY 会重建整表,因此必须先比对注释,避免每次启动都白跑一次 DDL。
async fn ensure_column_comment(
pool: &MySqlPool,
table: &str,
column: &str,
marker: &str,
ddl: &str,
) -> Result<(), sqlx::Error> {
// ⚠️ `information_schema.COLUMNS.COLUMN_COMMENT` 在 MySQL 8 上会被 sqlx 报告为
// **VARBINARY**,直接按 `Option<String>` 解码会 panic(实测阿里云 RDS MySQL 8.0.36):
// `mismatched types; Rust type String (as SQL type VARCHAR) is not compatible with SQL type VARBINARY`
// 必须显式 `CAST(... AS CHAR)`。
let comment: Option<String> = sqlx::query_scalar(
"SELECT CAST(COLUMN_COMMENT AS CHAR) FROM information_schema.COLUMNS \
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?",
)
.bind(table)
.bind(column)
.fetch_optional(pool)
.await?;
if let Some(current) = comment {
if !current.contains(marker) {
sqlx::query(ddl).execute(pool).await?;
tracing::info!("[migrate] 更新列注释 {}.{}", table, column);
}
}
Ok(())
}
/// 逐列补齐历史版本遗漏的列(幂等,兼容线上已存在的旧表)
///
/// 线上 pms 库曾因缺少 `cabinets.auth_str_issued` / `cabinets.cell`,
/// 导致设备认证链路直接报 `1054 Unknown column`,只能人工 ALTER 补救;
/// 本函数就是把这个人工步骤固化进启动迁移。
async fn add_columns_if_not_exists(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// users:头像 + 微信 openid
ensure_column(pool, "users", "avatar", "VARCHAR(500) NULL COMMENT '头像URL'").await?;
ensure_column(pool, "users", "openid", "VARCHAR(64) NULL UNIQUE COMMENT '微信openid'")
.await?;
// organizations:父组织(组织树层级)+ 禁用标记(甲方禁用客户组织,其下柜子全部失效)
ensure_column(pool, "organizations", "parent_id", "BIGINT NULL COMMENT '父组织ID(NULL=顶级组织)'")
.await?;
ensure_column(
pool,
"organizations",
"disabled",
"TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1禁用'",
)
.await?;
// projects:地址 + 经纬度
ensure_column(pool, "projects", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?;
ensure_column(pool, "projects", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?;
ensure_column(pool, "projects", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?;
// cabinets:地址 + 经纬度
ensure_column(pool, "cabinets", "address", "VARCHAR(200) NULL COMMENT '详细地址'").await?;
ensure_column(pool, "cabinets", "latitude", "DECIMAL(10,7) NULL COMMENT '纬度'").await?;
ensure_column(pool, "cabinets", "longitude", "DECIMAL(10,7) NULL COMMENT '经度'").await?;
// cabinets:设备认证 / 拓扑 / 工作模式(线上曾缺失,必须补齐)
ensure_column(
pool,
"cabinets",
"auth_str_issued",
"BOOLEAN DEFAULT FALSE COMMENT '安全码是否已下发'",
)
.await?;
ensure_column(pool, "cabinets", "cell", "TINYINT DEFAULT 6 COMMENT '仓控板数量'").await?;
ensure_column(
pool,
"cabinets",
"work_mode",
"TINYINT DEFAULT 0 COMMENT '充电工作模式: 0=平台指令模式(默认), 1=自动充电工作模式'",
)
.await?;
// batteries:协议 §5.5「电池属性」的原值字段(老库缺失,必须补齐,否则台账只能丢到 device_logs)
ensure_column(pool, "batteries", "brand_code", "VARCHAR(20) NULL COMMENT '厂家代码(原值透传)'").await?;
ensure_column(pool, "batteries", "model_code", "VARCHAR(20) NULL COMMENT '电池型号代码(原值透传)'").await?;
ensure_column(pool, "batteries", "material_code", "VARCHAR(20) NULL COMMENT '电芯材料代码(原值透传)'")
.await?;
ensure_column(pool, "batteries", "cell_count", "INT NULL COMMENT '电芯节数'").await?;
ensure_column(pool, "batteries", "rated_voltage", "BIGINT NULL COMMENT '额定电压(mV)'").await?;
ensure_column(pool, "batteries", "rated_capacity", "BIGINT NULL COMMENT '额定容量(mAh)'").await?;
// user_feedback:反馈图片(JSON 数组字符串)
ensure_column(
pool,
"user_feedback",
"images",
"TEXT NULL COMMENT '反馈图片URL列表(JSON数组)'",
)
.await?;
tracing::info!("数据库列级迁移完成");
Ok(())
}
/// 插入默认权限码
async fn seed_permissions(pool: &MySqlPool) -> Result<(), sqlx::Error> {
let perms: Vec<(&str, &str)> = vec![
("device:view", "查看设备"),
("device:operate", "操作设备"),
("device:create", "添加设备"),
("device:edit", "编辑设备"),
("device:delete", "删除设备"),
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
// 安全码重置(仅总管理员,不授予企业管理员/操作员)
("device:auth_str:reset", "重置安全码"),
("charge:start", "开始充电"),
("charge:stop", "停止充电"),
("charge:open_door", "开门"),
("charge_record:view", "查看充电记录"),
("charge_record:export", "导出充电记录"),
("device_log:view", "查看设备日志"),
("energy:view", "查看能耗"),
("energy:export", "导出能耗"),
("user:view", "查看用户"),
("user:create", "创建用户"),
("user:edit", "编辑用户"),
("user:delete", "删除用户"),
("role:view", "查看角色"),
("role:create", "创建角色"),
("role:edit", "编辑角色"),
("role:delete", "删除角色"),
("operation_log:view", "查看操作日志"),
// 组织管理
("org:view", "查看组织"),
("org:create", "创建组织"),
("org:edit", "编辑组织"),
("org:delete", "删除组织"),
// 项目管理
("project:view", "查看项目"),
("project:create", "创建项目"),
("project:edit", "编辑项目"),
("project:delete", "删除项目"),
];
for (code, name) in perms {
sqlx::query(
"INSERT IGNORE INTO permissions (code, name) VALUES (?, ?)"
)
.bind(code)
.bind(name)
.execute(pool)
.await?;
}
tracing::info!("默认权限码初始化完成");
Ok(())
}
/// 创建默认角色并分配权限
async fn seed_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
insert_default_roles(pool).await?;
// 为总管理员分配所有权限
let super_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'super_admin'"
)
.fetch_optional(pool)
.await?;
if let Some((role_id,)) = super_admin_id {
grant_all_permissions(pool, role_id).await?;
}
// 为企业管理员分配非系统管理权限
let org_admin_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'org_admin'"
)
.fetch_optional(pool)
.await?;
let org_perm_codes = [
"device:view", "device:operate", "device:create", "device:edit",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "charge_record:export",
"device_log:view", "energy:view", "energy:export",
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
"user:view", "user:create", "user:edit",
"operation_log:view",
// 组织管理
"org:view", "org:create", "org:edit", "org:delete",
// 项目管理
"project:view", "project:create", "project:edit", "project:delete",
];
if let Some((role_id,)) = org_admin_id {
grant_permissions_by_codes(pool, role_id, &org_perm_codes).await?;
}
feat(protocol): TCP v1.1 协议对齐 + 平台侧兜底 + 生产部署修复 协议(唯一权威文档:docs/柜控板固件对接文档.md) - 应答必带 act(与请求一致)、msg_id 与请求一致;判"应答 vs 指令"只看有没有 suc - dev_id 只在 auth_str/login 携带;其余报文用内部字段 _dev_id 路由(下发前由 device-server 摘除) - id 只表示动作目标(柜级省略 / 一段=单板 / 两段=仓体级),不再含 IMEI - msg_id 每连接从 1 起自增;60 秒交互超时;status_post 每通道 18 字符 hex 全量上报 - 未登录连接 30 秒内未 login 即断开;未登录只放行 auth_str/login 平台侧实现 - 上行解析按新结构重写(cabinet/boards/battery),删除废弃的 status/parsed_status - 订单:唯一入口 = on;bat_in 只登记电池台账;4 条结束路径统一走 finalize_order(含充电记录落库) - 新增设备日志保留策略(90 天,分批删除);未登记 IMEI 静默丢弃;停电标记按 status1 bit0 恢复清除 - 待确认第 13 项兜底:平台侧按 电压 × 电流 × 时间 积分计算订单电量(workers/energy.rs) - 待确认第 18 项兜底:status_post bit7 连续 2 次无电池 → 判定电池被拔走并提前结单 修复(均为部署后才暴露的问题) - 队列消费禁用 BRPOP:无限阻塞会因代理掐连接永久卡死(零错误日志);有限超时也只是 "超时才醒",下行指令延迟 15~45 秒 → 改为 RPOP 非阻塞轮询,延迟降到 0.1~0.2 秒 - sync_iccid 把可空列 iccid 按 Option<String> 解码 → 整条 login 失败(ICCID 未上报的新柜子永远登录不上) - 迁移读 information_schema.COLUMN_COMMENT 需 CAST(... AS CHAR)(MySQL 8 驱动报 VARBINARY,否则迁移 panic) - 部署注意事项:.env 不支持行内注释;pscp 上传需 chmod +x;运行中二进制需 .new + mv 原子替换 部署 - 生产迁到 API 9000 / TCP 9001(systemd 单元补 EnvironmentFile、nginx 同步指向) - 新增 deploy/deploy-prod.ps1(本地一键:构建→校验→备份→原子替换→重启→验收→无副作用探测) - 废弃 deploy-prod.sh / deploy-test.sh(原脚本会在 target/release 找产物、装重复 systemd 单元、 并用本地 .env 覆盖线上 .env) 质量 - 单测:api-server 39 个 / device-server 13 个(含协议示例逐值断言、跨模块 Redis key 契约) - 两服务 cargo check 零错误零告警;web tsc --noEmit 通过 - test/tcp_e2e_smoke.py 端到端联调 13/13 通过
2026-09-19 23:40:46 +08:00
// 为操作员分配查看和操作权限
let normal_id: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM roles WHERE name = 'normal_user'"
)
.fetch_optional(pool)
.await?;
let normal_perm_codes = [
"device:view", "device:operate",
"charge:start", "charge:stop", "charge:open_door",
"charge_record:view", "device_log:view", "energy:view",
];
if let Some((role_id,)) = normal_id {
grant_permissions_by_codes(pool, role_id, &normal_perm_codes).await?;
}
tracing::info!("默认角色及权限分配完成");
Ok(())
}
/// 创建三个默认角色(INSERT IGNORE,已存在则跳过)
async fn insert_default_roles(pool: &MySqlPool) -> Result<(), sqlx::Error> {
// 创建总管理员角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("super_admin")
.bind("总管理员 — 全部权限")
.execute(pool)
.await?;
// 创建企业管理员角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("org_admin")
.bind("企业管理员 — 管理本组织下项目/设备/人员")
.execute(pool)
.await?;
// 创建操作员角色
sqlx::query(
"INSERT IGNORE INTO roles (name, description) VALUES (?, ?)"
)
.bind("normal_user")
.bind("操作员 — 查看/操作指定范围设备")
.execute(pool)
.await?;
Ok(())
}
/// 为指定角色分配全部权限(授权总管理员)
async fn grant_all_permissions(pool: &MySqlPool, role_id: i64) -> Result<(), sqlx::Error> {
// 查询所有权限ID
let perm_ids: Vec<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions"
)
.fetch_all(pool)
.await?;
for (perm_id,) in perm_ids {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
Ok(())
}
/// 按权限码逐个为指定角色授权(权限码不存在则跳过)
async fn grant_permissions_by_codes(
pool: &MySqlPool,
role_id: i64,
codes: &[&str],
) -> Result<(), sqlx::Error> {
for code in codes {
let perm: Option<(i64,)> = sqlx::query_as(
"SELECT id FROM permissions WHERE code = ?"
)
.bind(code)
.fetch_optional(pool)
.await?;
if let Some((perm_id,)) = perm {
sqlx::query(
"INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)"
)
.bind(role_id)
.bind(perm_id)
.execute(pool)
.await?;
}
}
Ok(())
}