commit 12395ee9e25de3f8fee92274655c783e36d25ac7
Author: 12451 <12451@example.com>
Date: Thu Jul 2 05:38:01 2026 +0800
init: PMS充电柜管理系统源代码
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..73df920
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,22 @@
+# Rust
+target/
+target-fresh/
+
+# Node
+node_modules/
+
+# IDE
+.vscode/
+.idea/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Environment
+.env
+.env.local
+
+# Temporary
+*.tmp
+*.log
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..37963d2
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,258 @@
+# AGENTS.md - 充电柜项目工作手册
+
+## 项目简介
+
+充电柜矩阵系统(Power Matrix System / PMS)
+产品名:安知充(anzhizhichong),已有充电站平台在运行,充电柜是新增产品线。
+仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
+
+## 设备标识
+
+- **平台抽象ID**:`10-00000000`(绑定柜控板,二维码对应此ID)
+- **设备ID**:4G SIM卡15位IMEI(10进制)
+- **仓体全局ID**:`平台层ID-仓板ID-仓体ID`
+- **扫码链接**:`https://app.anzhizhichong.com/pms/{抽象ID}`
+
+## 技术栈
+
+- 后端:Rust + Axum
+- 前端:React + Arco Design Pro(React版)
+- 数据库:MySQL(阿里云RDS),开发/生产共用
+- 缓存:Redis(阿里云Redis),开发/生产共用
+- 设备通讯:TCP长连接(柜控板4G模块,Air780E Cat.1)
+- 权限:RBAC,精确到按钮级别(权限码如 device:door:unlock)
+
+## 项目结构
+
+```
+D:\charging-cabinet\
+├── tasks/ # 任务文件(opencode执行)
+├── docs/ # 开发文档、协议文档
+├── software/
+│ ├── server/ # Rust后端
+│ └── web/ # React前端
+├── hardware/ # 硬件资料(原理图/PCB等)
+└── test/ # 测试用例/报告
+```
+
+## 部署架构
+
+### 环境规划
+
+| 环境 | 域名 | 反代指向 | Nginx配置 |
+|------|------|---------|----------|
+| 正式环境 | app.anzhizhichong.com | 127.0.0.1:3000(服务器本地) | /etc/nginx/conf.d/app.anzhizhichong.com.conf |
+| 测试环境 | test.anzhizhichong.com | 10.8.0.248:3001(本地电脑) | /etc/nginx/conf.d/test.anzhizhichong.com.conf |
+
+### 服务器信息
+
+- **外网IP**: 118.31.119.206
+- **内网IP**: 172.25.42.239
+- **系统**: CentOS Stream 9(2核/3.5G/49G)
+- **ZeroTier**: 网络ID `63e843dd42f54b9c`,服务器节点 `12a91dd47d`,IP `10.8.0.252`
+- **本地电脑ZeroTier IP**: `10.8.0.248`
+- **已装软件**: Nginx 1.20.1、ZeroTier 1.16.2、socat(开机自启)
+- **服务**: mysql-proxy(3306→RDS)、redis-proxy(6379→Redis)、Gitea(3080)、acme.sh
+- **不装**: Docker/Node/Rust(本地编译传二进制)
+- **SSH**: plink连接,首次需 `echo y` 接受host key
+
+### 本地开发环境
+
+**数据库连接(通过ZeroTier):**
+```
+MySQL: 10.8.0.252:3306
+ user: root
+ password: Hbhyg731024@
+ database: pms_dev (新建,不动AZZCWeChat*)
+
+Redis: 10.8.0.252:6379
+ password: Hbhyg731024@
+ db: 5 (新建,不动db1-4)
+```
+
+**代理说明:**
+- 服务器nginx stream转发MySQL(3306)和Redis(6379)到阿里云RDS
+- systemd自启,`Restart=always` 自动恢复
+- 本地直连 `10.8.0.252`,无需SSH隧道
+
+### 证书
+
+- 阿里云免费SSL证书(acme.sh自动续期)
+- 存放路径: `/etc/nginx/ssl/{域名}.pem` + `/etc/nginx/ssl/{域名}.key`
+- 已配置: app.anzhizhichong.com、test.anzhizhichong.com
+
+### 内部服务
+
+- **Gitea**: `https://app.anzhizhichong.com/doc/` (端口3080)
+ - 组织: anzhizhichong
+ - 仓库: charging-cabinet
+ - 用户: admin / skyp76
+
+## 开发约束
+
+### 禁止操作
+
+- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
+- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
+
+### 编码规范
+
+- 任务需求写到 `tasks/` 目录的md文件
+- 通过Agent子代理包裹opencode执行,异步通知
+- 任务要拆的尽量细
+- 质量约束模板固定追加到每个任务文件
+- opencode超时必须报告用户,不能fallback手动执行
+- 审查用不同模型(不用开发同款)
+
+### 编码任务下发
+
+- 任务需求写到 `tasks/` 目录的md文件
+- 通过Agent子代理包裹opencode执行,异步通知
+- 质量约束模板固定追加到每个任务文件
+
+### 质量约束模板
+
+所有编码任务必须追加以下约束:
+
+1. 完成代码后执行对应栈静态校验(Rust→cargo check,TS→tsc --noEmit),零报错零警告
+2. 分层拆分,单函数≤80行,命名语义化,完整注释
+3. 所有外部IO/网络请求异常捕获,禁止裸panic
+4. 分支逻辑全覆盖,不遗漏兜底分支
+5. 常量抽离,不使用废弃API
+6. Rust:内存安全,合理管理所有权,禁用unsafe无合理理由
+7. React:函数式组件+Hooks,不写Class组件
+
+### 验收流程
+
+1. 静态校验命令通过
+2. 跑起来看效果
+3. 抽查关键逻辑(异常处理、权限校验、协议解析)
+
+## 硬件架构
+
+- 柜控板(ID128)×1 + 仓控板(ID1-N)×N,每仓控板管6个仓体
+- 仓控板与电源:1TN RS485协议(翌工)
+- 仓控板与BMS:星恒Modbus / 天能485 / 无协议(平台可配置)
+- 仓控板与柜控板:自定义485协议(14+功能码)
+- 柜控板与后台:4G TCP长连接(Air780E模块)
+
+## 开发范围(当前阶段)
+
+### 做
+
+- 后台管理系统(Web端)
+- 用户端H5页面(`/pms/{id}`,微信扫码可访问)
+- 设备TCP通讯服务(协议文档已就绪)
+
+### 不做
+
+- 微信小程序(名额暂不可用,先用H5)
+- 支付/计费(本系统不涉及)
+
+## 组织架构
+
+```
+组织(企业) → 项目(放置地点) → 设备(充电柜)
+```
+
+设备卖给哪个企业就划拨给该组织,设备放置的具体地点称为项目,设备必须划拨到项目下。
+
+## 数据模型
+
+### 核心关系
+
+```
+organization(组织/企业)
+├── project(项目/放置地点)
+│ └── cabinet(充电柜)
+│ ├── cabin_board(仓控板×N)
+│ │ └── compartment(仓体×6)
+│ ├── charge_record(充电记录)
+│ └── energy_stat(能耗统计)
+├── user(用户)
+└── device_log(设备日志)
+
+operation_log(操作日志)
+```
+
+### 数据库表
+
+| 表名 | 说明 |
+|------|------|
+| organizations | 组织表 |
+| projects | 项目表 |
+| cabinets | 充电柜表(IMEI、abstract_id、auth_str) |
+| cabin_boards | 仓控板表 |
+| compartments | 仓体表 |
+| users | 用户表(手机号登录) |
+| charge_records | 充电记录表 |
+| device_logs | 设备日志表 |
+| energy_stats | 能耗统计表 |
+| operation_logs | 操作日志表 |
+
+## TCP通讯协议
+
+- 长连接,无需PING包
+- 空闲1分钟上报,充电15秒上报
+- 超时5000ms
+- JSON去空格回车省流量,LF处理粘包
+- `dev_id`:IMEI(10进制),`sub_device_id`:485 ID
+- `msg_id`:自增会话ID
+- 全局通道号:`dev_id-sub_device_id-通道号`(如`123456789012345-01-6`)
+
+### 签名机制
+
+- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次)
+- `sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)`
+- 时间戳秒级,平台校验±60秒
+
+### 指令清单
+
+| 动作 | 方向 | id格式 | 说明 |
+|------|------|--------|------|
+| `login` | 设备→平台 | 无 | 登录(含sign+timestamp) |
+| `auth_str` | 设备→平台 | 无 | 获取安全码(仅首次) |
+| `status_post` | 设备→平台 | 无 | 定时上报(rssi+status字典) |
+| `pow_fail` | 设备→平台 | 无 | 停电上报 |
+| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入(含BMS数据,字段待定) |
+| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
+| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电(平台下发/设备上报) |
+| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电(平台手动/设备自动) |
+| `open` | 平台→设备 | `IMEI` | 开门 |
+| `status_get` | 平台→设备 | `IMEI-仓控板` | 查仓板状态 |
+| `reset` | 平台→设备 | `IMEI` 或 `IMEI-仓控板` | 重启整柜或仓控板 |
+
+### status_post格式
+
+```json
+{
+ "act": "status_post",
+ "msg_id": 42,
+ "dev_id": "123456789012345",
+ "rssi": 85,
+ "status": {
+ "123456789012345-01": "000100020003000400050006",
+ "123456789012345-02": "000100020003000400050006"
+ }
+}
+```
+
+- key: `IMEI-仓控板ID`
+- value: 6个通道状态拼接(每通道2字节=4个HEX字符)
+- 平台从key解析仓控板数量
+
+### 充电结束原因(平台判断)
+
+| 平台收到 | 判断原因 |
+|---------|---------|
+| `off` (设备→平台) | `normal` — 正常充满 |
+| `off` (平台→设备) | `user_stop` — 用户手动停止 |
+| `bat_out` | `normal` — 电池拔出 |
+| `bat_in` (通道已有电池) | `forced` — 新电池插入,旧电池强制结束 |
+| 超时未响应 | `timeout` — 通信超时 |
+
+## H5用户端
+
+- 路径:`/pms/{抽象ID}`
+- 功能:显示柜子每个通道状态,可开始/停止充电
+- 无支付/计费功能
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..158d0ee
--- /dev/null
+++ b/README.md
@@ -0,0 +1,348 @@
+# 充电柜矩阵系统 (Power Matrix System / PMS)
+
+产品名:安知充(anzhizhichong)
+
+## 项目简介
+
+36仓体直流电瓶车充电柜,仓数不固定:1柜控板+N仓控板,每仓控板管6仓,最小6仓。
+
+## 技术栈
+
+- **后端**:Rust + Axum
+- **前端**:React + Arco Design Pro
+- **数据库**:MySQL(阿里云RDS)
+- **缓存**:Redis(阿里云Redis)
+- **设备通讯**:TCP长连接(柜控板4G模块,Air780E Cat.1)
+
+## 项目结构
+
+```
+charging-cabinet/
+├── tasks/ # 任务文件(opencode执行)
+├── docs/ # 开发文档、协议文档
+├── software/
+│ ├── server/ # Rust后端
+│ └── web/ # React前端
+├── hardware/ # 硬件资料(原理图/PCB等)
+└── test/ # 测试用例/报告
+```
+
+## 部署架构
+
+| 环境 | 域名 | 反代指向 |
+|------|------|---------|
+| 正式环境 | app.anzhizhichong.com | 127.0.0.1:3000(服务器本地) |
+| 测试环境 | test.anzhizhichong.com | 10.8.0.248:3001(本地电脑) |
+
+**服务器**:118.31.119.206(CentOS Stream 9,2核/3.5G/49G)
+
+**内部服务**:
+- Gitea: https://app.anzhizhichong.com/doc/
+- 文档Wiki: https://app.anzhizhichong.com/doc/anzhizhichong/charging-cabinet/wiki/
+
+## 服务架构
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ API服务 (api-server) — 主服务 │
+│ - HTTP API(后台管理 + H5)端口: 3000 │
+│ - 前端轮询刷新(每5秒) │
+│ - 业务逻辑处理(验证/存DB/告警) │
+│ - 节点管理(设备服务注册/心跳) │
+│ - 后台协程处理Redis队列 │
+└─────────────────────────────────────────────────────────────┘
+ ↑ Redis LIST ↑ Redis LIST
+ │ device:report:{imei} │ device:cmd:{imei}
+ │ (设备上报) │ (平台指令)
+ │ │
+┌─────────────────────────────────────────────────────────────┐
+│ 设备服务 (device-server) — 轻量化 │
+│ - TCP监听: 3002(设备长连接) │
+│ - 只处理通信和指令解析 │
+│ - 不存DB、不验证签名、不处理业务 │
+│ - 注册到API服务(启动/心跳/注销) │
+│ - Redis连接注册: device:{imei} → {node_id, ip, port} │
+└─────────────────────────────────────────────────────────────┘
+```
+
+**核心设计原则:**
+1. **TCP服务轻量化** — 只处理TCP通信和指令解析,不处理业务逻辑
+2. **Redis做缓冲** — 服务间通信用Redis LIST,不用HTTP直接调用
+3. **全异步处理** — 所有指令异步处理,msg_id匹配响应
+4. **协程处理任务** — API服务用tokio::spawn处理Redis队列消息
+
+**通信流程:**
+- **设备上报**:设备 → TCP → 设备服务 → Redis LIST → API服务协程处理
+- **平台指令**:API服务 → Redis LIST → 设备服务 → TCP → 设备
+- **指令响应**:设备 → TCP → 设备服务 → Redis LIST → API服务匹配msg_id
+
+**节点管理:**
+- 设备服务启动时注册到API服务
+- 每60秒心跳,180秒无心跳视为离线
+- 支持多节点扩展(当前单节点)
+
+## TCP通讯协议
+
+### 通信规则
+
+- TCP长连接,无需PING包
+- 空闲1分钟上报,充电15秒上报
+- 超时5000ms
+- JSON去空格回车省流量,LF处理粘包
+- `dev_id`:IMEI(10进制),`sub_device_id`:485 ID
+- `msg_id`:自增会话ID
+- 全局通道号:`dev_id-sub_device_id-通道号`(如`123456789012345-01-6`)
+
+### 签名机制
+
+- 设备首次连接调 `auth_str` 接口获取8位安全码(仅一次)
+- `sign = SHA256(dev_id + auth_str + timestamp).substring(56, 63)`
+- 时间戳秒级,平台校验±60秒
+
+### 指令清单
+
+| 动作 | 方向 | id格式 | 说明 |
+|------|------|--------|------|
+| `login` | 设备→平台 | 无 | 登录(含sign+timestamp) |
+| `auth_str` | 设备→平台 | 无 | 获取安全码(仅首次) |
+| `status_post` | 设备→平台 | 无 | 定时上报(rssi+status字典) |
+| `pow_fail` | 设备→平台 | 无 | 停电上报 |
+| `bat_in` | 设备→平台 | `IMEI-仓控板-通道` | 电池插入(含BMS数据,字段待定) |
+| `bat_out` | 设备→平台 | `IMEI-仓控板-通道` | 电池拔出 |
+| `on` | **双向** | `IMEI-仓控板-通道` | 开始充电(平台下发/设备上报) |
+| `off` | **双向** | `IMEI-仓控板-通道` | 停止供电(平台手动/设备自动) |
+| `open` | 平台→设备 | `IMEI` | 开门 |
+| `status_get` | 平台→设备 | `IMEI-仓控板` | 查仓板状态 |
+| `reset` | 平台→设备 | `IMEI` 或 `IMEI-仓控板` | 重启整柜或仓控板 |
+
+### status_post格式
+
+```json
+{
+ "act": "status_post",
+ "msg_id": 42,
+ "dev_id": "123456789012345",
+ "rssi": 85,
+ "status": {
+ "123456789012345-01": "000100020003000400050006",
+ "123456789012345-02": "000100020003000400050006"
+ }
+}
+```
+
+- key: `IMEI-仓控板ID`
+- value: 6个通道状态拼接(每通道2字节=4个HEX字符)
+- 平台从key解析仓控板数量
+
+### 指令详情
+
+#### login(设备→平台)
+
+设备登录,验证签名。
+
+```json
+// 请求
+{"act":"login","msg_id":1,"dev_id":"123456789012345","sign":"abc12345","timestamp":1719800000,"h_ver":"1.0.0","s_ver":"1.0.0"}
+
+// 响应
+{"suc":1,"msg_id":1,"cell":6}
+```
+
+**字段说明:**
+- `sign` — 签名(SHA256(dev_id+auth_str+timestamp)后8位)
+- `timestamp` — 秒级时间戳(平台校验±60秒)
+- `h_ver` — 硬件版本
+- `s_ver` — 软件版本
+- `cell` — 仓控板数量
+
+---
+
+#### auth_str(设备→平台)
+
+获取8位安全码,仅首次连接调用。
+
+```json
+// 请求
+{"act":"auth_str","msg_id":2,"dev_id":"123456789012345","ccid":"89860000000000000000"}
+
+// 响应
+{"suc":1,"msg_id":2,"data":{"auth_str":"abcd1234"}}
+```
+
+**字段说明:**
+- `ccid` — SIM卡ICCID号
+- `auth_str` — 8位随机安全码(仅返回一次)
+
+---
+
+#### status_post(设备→平台)
+
+定时上报,空闲60秒/充电15秒。
+
+```json
+// 请求
+{"act":"status_post","msg_id":10,"dev_id":"123456789012345","rssi":85,"status":{"123456789012345-01":"000100020003000400050006","123456789012345-02":"000100020003000400050006"}}
+
+// 响应
+{"suc":1,"msg_id":10}
+```
+
+**status字段说明:**
+- key: `IMEI-仓控板ID`
+- value: 6个通道状态拼接(每通道2字节=4个HEX字符)
+- 平台从key解析仓控板数量
+
+---
+
+#### pow_fail(设备→平台)
+
+停电上报。
+
+```json
+// 请求
+{"act":"pow_fail","msg_id":15,"dev_id":"123456789012345"}
+
+// 响应
+{"suc":1,"msg_id":15}
+```
+
+---
+
+#### bat_in(设备→平台)
+
+电池插入事件,立即上报。
+
+```json
+// 请求
+{"act":"bat_in","msg_id":20,"id":"123456789012345-01-6","bms":{"soc":45,"voltage":52.3}}
+
+// 响应
+{"suc":1,"msg_id":20}
+```
+
+**平台处理:**
+1. 记录电池信息
+2. 验证电池合规性
+3. 自动下发 `on` 指令
+
+**BMS数据字段待定。**
+
+---
+
+#### bat_out(设备→平台)
+
+电池拔出事件,立即上报。
+
+```json
+// 请求
+{"act":"bat_out","msg_id":21,"id":"123456789012345-01-6"}
+
+// 响应
+{"suc":1,"msg_id":21}
+```
+
+---
+
+#### on(双向)
+
+**平台→设备:** 开始充电
+
+```json
+// 请求
+{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
+
+// 响应
+{"suc":1,"msg_id":30}
+```
+
+**设备→平台:** 充电开始确认(自动充电模式)
+
+```json
+// 上报
+{"act":"on","msg_id":30,"id":"123456789012345-01-6"}
+```
+
+---
+
+#### off(双向)
+
+**平台→设备:** 手动停止充电
+
+```json
+// 请求
+{"act":"off","msg_id":31,"id":"123456789012345-01-6"}
+
+// 响应
+{"suc":1,"msg_id":31}
+```
+
+**设备→平台:** 充满自动停止
+
+```json
+// 上报
+{"act":"off","msg_id":31,"id":"123456789012345-01-6"}
+```
+
+---
+
+#### open(平台→设备)
+
+开门,只需IMEI。
+
+```json
+// 请求
+{"act":"open","msg_id":40,"id":"123456789012345"}
+
+// 响应
+{"suc":1,"msg_id":40}
+```
+
+**错误码63 = 门锁故障。**
+
+---
+
+#### status_get(平台→设备)
+
+查询仓板状态。
+
+```json
+// 请求
+{"act":"status_get","msg_id":50,"id":"123456789012345-01"}
+
+// 响应
+{"suc":1,"msg_id":50,"data":{"status":"..."}}
+```
+
+---
+
+#### reset(平台→设备)
+
+重启整柜或仓控板。
+
+```json
+// 重启整柜
+{"act":"reset","msg_id":60,"id":"123456789012345"}
+
+// 重启仓控板
+{"act":"reset","msg_id":61,"id":"123456789012345-01"}
+
+// 响应
+{"suc":1,"msg_id":60}
+```
+
+### 充电结束原因(平台判断)
+
+| 平台收到 | 判断原因 |
+|---------|---------|
+| `off` (设备→平台) | `normal` — 正常充满 |
+| `off` (平台→设备) | `user_stop` — 用户手动停止 |
+| `bat_out` | `normal` — 电池拔出 |
+| `bat_in` (通道已有电池) | `forced` — 新电池插入,旧电池强制结束 |
+| 超时未响应 | `timeout` — 通信超时 |
+
+## 开发规范
+
+- 任务需求写到 `tasks/` 目录的md文件
+- 通过Agent子代理包裹opencode执行,异步通知
+- 质量约束模板固定追加到每个任务文件
+- 完成代码后执行对应栈静态校验(Rust→cargo check,TS→tsc --noEmit),零报错零警告
diff --git a/docs/1TN协议内容_完整.txt b/docs/1TN协议内容_完整.txt
new file mode 100644
index 0000000..61fcba5
--- /dev/null
+++ b/docs/1TN协议内容_完整.txt
@@ -0,0 +1,384 @@
+--- Page 1 ---
+-1-
+-
+技
+术
+标
+准
+标准号:YG20240118-1
+换电柜用锂电池充电器
+1TN 标准通信协议
+RS485
+2024 年1 月
+版
+本:1.0
+苏州翌工电源科技有限公司
+研发中心
+
+--- Page 2 ---
+-2-
+-
+版本修改记录:
+版本
+修改内容
+时间
+修改人
+S1.0
+初步建立1TN RS485 协议内容。
+2024-01-18
+Eleven
+
+--- Page 3 ---
+-3-
+-
+一、
+概述
+换电柜1TN 多路充电器支持通过RS485 总线进行通信,通信协议为客户自定义协议,
+采用Mod Bus-BTU 传输模式,波特率9600。
+Mod Bus-BTU 传输模式在一根通讯线上采用主从应答方式的通讯连接方式。由主机发
+起,主机的信号寻址到一台唯一地址的终端设备(从机),然后终端设备发出的应答信号以
+相反的方向传输给主机,即半双工的工作模式。该协议只允许在主机和终端设备之间通讯,
+而不允许独立的终端设备之间的数据交换,这样各终端设备不会在它们初始化时占据通讯线
+路,而仅限于响应到达本机的查询信号。
+1.1 通讯格式
+信息传输为异步方式,并以字节为单位,在主机和从机之间传递的通讯信息是11 位的
+字格式。包含1 个起始位(0),8 个数据位和1 个停止位。
+信息帧格式:
+地址
+通道
+功能码
+长度
+数据区
+校验码
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+-
+0x03
+0x00
+-
+0x0X
+0x00
+-
+0xFF
+0x01-0xFF
+DATA
+有效字节数
+读写有效的数据内
+容,采用BigEndian
+网络字节序
+SUM =(ADDR +
+CHx+CMD+LEN
++DATA)按字节
+相加,取低8 位
+1 个字节
+1 个字节
+1 个字节
+1 个字节
+LEN 个字节
+1 个字节
+1.2 通讯信息过程
+信息发送至从机时,从机接收通讯命令,如果SUM 校验无误则执行相应的操作,然后
+把执行结果(数据)返送给主机。返回的信息中包括Header、CMD、ATS、执行后的数据以
+及SUM 校验码和END。如果CRC 校验出错就不返回任何信息,则分析返回相应的错误
+信息。
+1.2.1
+地址码(ADDR)
+对应电源双拨码开关,01 代表0x01,10 代表0x02,11 代表0x03。
+对应电源双拨码开关,001 代表0x01,010 代表0x02,011 代表0x03,100 代表0x04,
+101 代表0x05,110 代表0x06,111 代表0x07。
+1.2.2
+通道码(CHx)
+对应各个通道识别码。
+通道
+Bit1-7
+Bit0
+CHx Byte 解析
+通道
+Bit1-7
+Bit0
+CHx Byte 解析
+CH1
+00
+0/1
+00:CH1 主通道
+01:CH1 副通道
+CH4
+03
+0/1
+06:CH4 主通道
+07:CH4 副通道
+
+--- Page 4 ---
+-4-
+-
+CH2
+01
+0/1
+02:CH2 主通道
+03:CH2 副通道
+CH5
+04
+0/1
+08:CH5 主通道
+09:CH5 副通道
+CH3
+02
+0/1
+04:CH3 主通道
+05:CH3 副通道
+CH6
+05
+0/1
+0A:CH6 主通道
+0B:CH6 副通道
+1.2.3
+功能码(CMD)
+每个通讯信息帧的第4 个字节。主机发送,通过功能码告诉从机应执行什么动作。从
+机响应,从机返回的功能码与从主机发送来的功能码一样,表明从机已响应主机并已执行了
+相关的操作。本协议支持以下功能码:
+序号
+命令
+CMD1
+LEN
+备注
+1
+设置通道
+输出电压/电流
+0x02
+0x04
+2
+设置通道
+开关机
+0x03
+0x01
+3
+通道组合指令
+开关机/调压/调流
+0x04
+0x05
+4
+电源通道信息查询
+0x05
+0x01
+5
+供应商电源型号/版本
+号/序列号查询
+0x12
+0x01
+10
+升级状态查询
+0x18
+NC
+详见升级
+协议
+11
+OTA 远程升级
+0x19
+NC
+详见升级
+协议
+1.2.4
+数据区长度(LEN)
+见上表
+1.2.5
+数据区(DATA)
+数据区随功能码不同而异。这些数据可以是数值、参考地址等。对于不同的从机地址和
+数据信息都不相同,应给出通讯信息表,详见例程。
+1.2.6
+校验区(SUM)
+校验区采用和校验方式,从地址码一直到数据域最后一位,求和并取低八位作为校验码。
+
+--- Page 5 ---
+-5-
+-
+指令解析:
+1. 设置1#电源CH1主通道输出电压电流
+主控发送给电源
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x02
+0x04
+0x02 58 00 64
+0xC5
+DATA:0x0258代表60.0V, 0x0064代表10.0A.
+电源回复给主控
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x02
+0x01
+0x01
+0x05
+回复:0x01代表设置输出电压电流成功
+2. 设置1#电源CH1主通道输出开关机
+主控发送给电源
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x03
+0x01
+0x01
+0x06
+开关机:0x01代表开机,0x00代表关机
+电源回复给主控
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x03
+0x01
+0x01
+0x06
+回复:0x01代表当前设置开关机成功
+3. 设置1#电源CH1主通道组合控制命令
+主控发送给电源
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x04
+0x05
+0x01 02 58 00 64
+0xC9
+DATA: 0x01代表开机状态,0258代表设置电压60.0V,0064代表设置电流10.0A
+电源回复给主控
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x04
+0x07
+0x02 58 00 64 01 01 1A
+0xE6
+回复DATA: 0258代表当前输出电压60.0V,0064代表当前输出电流10.0A,01 01代表故障码,1A代表环温26C.
+4. 读取1#电源CH1主通道运行信息
+主控发送给电源
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+
+--- Page 6 ---
+-6-
+-
+0x01
+0x00
+0x05
+0x01
+0x00
+0x07
+电源回复给主控
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x05
+0x0D
+02 58 00 64 01 01 1A 55 F0 03 E8 08 98
+0xBD
+回复DATA: 0x0258代表设置电压60.0V,0064代表设置电流10.0A,0x01 01代表故障码,1A代表环温26C,55F0输
+入电压220.00V,03E8输入电流10.00A,0898输入功率2200W
+故障码解析:
+故障字节1
+Bit0
+输入欠压
+故障字节1
+Bit0
+硬件故障
+Bit1
+输入过压
+Bit1
+PFC故障
+Bit2
+NC
+Bit2
+风扇故障
+Bit3
+输出过压
+Bit3
+电池反接故障
+Bit4
+输出欠压
+Bit4
+NC
+Bit5
+输出短路
+Bit5
+NC
+Bit6
+输出过流
+Bit6
+NC
+Bit7
+过温
+Bit7
+NC
+5. 读取1#电源供应商电源型号/版本号/序列号查询
+主控发送给电源
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x12
+0x01
+0x00
+0x14
+电源回复给主控
+ADDR
+CHx
+CMD
+LEN
+DATA
+SUM
+0x01
+0x00
+0x12
+0x0F
+‘YGCP1TN-A’ 01 01 18 02 17 00 01
++
+回复DATA: ASCII码’YG’代表厂商,CP1TN-A代表电源型号,S1.0V1.0代表硬件版本1软件版本1, 18 02 17
+代表24年2月23日,00 01代表序列号00001.
+
diff --git a/docs/1TN协议标准_YG20240118-1_V1.0.md b/docs/1TN协议标准_YG20240118-1_V1.0.md
new file mode 100644
index 0000000..da388e9
--- /dev/null
+++ b/docs/1TN协议标准_YG20240118-1_V1.0.md
@@ -0,0 +1,210 @@
+# 换电柜用锂电池充电器 1TN 标准通信协议 RS485
+
+- **标准号**:YG20240118-1
+- **版本**:V1.0
+- **制定方**:苏州翌工电源科技有限公司
+- **适用范围**:换电柜 1TN 多路充电器 RS485 总线通信
+
+---
+
+## 版本记录
+
+| 版本 | 修改内容 | 时间 | 修改人 |
+|------|---------|------|--------|
+| S1.0 | 初步建立1TN RS485协议内容 | 2024-01-18 | Eleven |
+
+---
+
+## 1. 概述
+
+换电柜 1TN 多路充电器支持通过 RS485 总线进行通信,通信协议为客户自定义协议,采用 Modbus-RTU 传输模式,波特率 9600。
+
+Modbus-RTU 传输模式在一根通讯线上采用主从应答方式的通讯连接方式。由主机发起,主机的信号寻址到一台唯一地址的终端设备(从机),然后终端设备发出的应答信号以相反的方向传输给主机,即半双工的工作模式。该协议只允许在主机和终端设备之间通讯,而不允许独立的终端设备之间的数据交换,这样各终端设备不会在它们初始化时占据通讯线路,而仅限于响应到达本机的查询信号。
+
+### 1.1 通讯格式
+
+信息传输为异步方式,并以字节为单位,在主机和从机之间传递的通讯信息是 11 位的字格式。包含 1 个起始位(0),8 个数据位和 1 个停止位。
+
+### 1.2 信息帧格式
+
+| 字段 | 字节数 | 说明 |
+|------|--------|------|
+| ADDR | 1 | 地址码 |
+| CHx | 1 | 通道码 |
+| CMD | 1 | 功能码 |
+| LEN | 1 | 数据区长度 |
+| DATA | LEN | 数据区(BigEndian 网络字节序) |
+| SUM | 1 | 校验码 = (ADDR + CHx + CMD + LEN + DATA) 按字节相加取低8位 |
+
+### 1.3 通讯过程
+
+信息发送至从机时,从机接收通讯命令,如果 SUM 校验无误则执行相应的操作,然后把执行结果(数据)返送给主机。返回的信息中包括 Header、CMD、ATS、执行后的数据以及 SUM 校验码和 END。如果校验出错就不返回任何信息,则分析返回相应的错误信息。
+
+---
+
+## 2. 地址码(ADDR)
+
+对应电源拨码开关。
+
+- **双拨码开关**:01→0x01, 10→0x02, 11→0x03
+- **3位拨码开关**:001→0x01, 010→0x02, 011→0x03, 100→0x04, 101→0x05, 110→0x06, 111→0x07
+
+---
+
+## 3. 通道码(CHx)
+
+对应各个通道识别码。
+
+| 通道 | Bit1-7 | CHx值(Bit0=0主通道) | CHx值(Bit0=1副通道) |
+|------|--------|----------------------|----------------------|
+| CH1 | 0x00 | 0x00 | 0x01 |
+| CH2 | 0x01 | 0x02 | 0x03 |
+| CH3 | 0x02 | 0x04 | 0x05 |
+| CH4 | 0x03 | 0x06 | 0x07 |
+| CH5 | 0x04 | 0x08 | 0x09 |
+| CH6 | 0x05 | 0x0A | 0x0B |
+
+---
+
+## 4. 功能码(CMD)
+
+| 序号 | 命令 | CMD | LEN | 备注 |
+|------|------|-----|-----|------|
+| 1 | 设置通道输出电压/电流 | 0x02 | 0x04 | |
+| 2 | 设置通道开关机 | 0x03 | 0x01 | |
+| 3 | 通道组合指令(开关机/调压/调流) | 0x04 | 0x05 | |
+| 4 | 电源通道信息查询 | 0x05 | 0x01 | |
+| 5 | 供应商电源型号/版本号/序列号查询 | 0x12 | 0x01 | |
+| 6 | 升级状态查询 | 0x18 | NC | 详见升级协议 |
+| 7 | OTA 远程升级 | 0x19 | NC | 详见升级协议 |
+
+---
+
+## 5. 故障码
+
+### 故障字节1
+
+| Bit | 含义 | Bit | 含义 |
+|-----|------|-----|------|
+| Bit0 | 输入欠压 | Bit4 | 输出欠压 |
+| Bit1 | 输入过压 | Bit5 | 输出短路 |
+| Bit2 | NC | Bit6 | 输出过流 |
+| Bit3 | 输出过压 | Bit7 | 过温 |
+
+### 故障字节2
+
+| Bit | 含义 | Bit | 含义 |
+|-----|------|-----|------|
+| Bit0 | 硬件故障 | Bit4 | NC |
+| Bit1 | PFC故障 | Bit5 | NC |
+| Bit2 | 风扇故障 | Bit6 | NC |
+| Bit3 | 电池反接故障 | Bit7 | NC |
+
+---
+
+## 6. 指令示例
+
+### 6.1 设置输出电压/电流(CMD=0x02)
+
+**主控→电源:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x02 | 0x04 | 0x02 0x58 0x00 0x64 | 0xC5 |
+
+DATA:0x0258 = 60.0V, 0x0064 = 10.0A
+
+**电源→主控:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x02 | 0x01 | 0x01 | 0x05 |
+
+回复:0x01 = 设置成功
+
+### 6.2 设置开关机(CMD=0x03)
+
+**主控→电源:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x03 | 0x01 | 0x01 | 0x06 |
+
+DATA:0x01 = 开机, 0x00 = 关机
+
+**电源→主控:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x03 | 0x01 | 0x01 | 0x06 |
+
+回复:0x01 = 设置成功
+
+### 6.3 组合控制命令(CMD=0x04)
+
+**主控→电源:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x04 | 0x05 | 0x01 0x02 0x58 0x00 0x64 | 0xC9 |
+
+DATA: 0x01 = 开机, 0x0258 = 60.0V, 0x0064 = 10.0A
+
+**电源→主控:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x04 | 0x07 | 0x02 0x58 0x00 0x64 0x01 0x01 0x1A | 0xE6 |
+
+回复DATA: 0x0258 = 当前电压60.0V, 0x0064 = 当前电流10.0A, 0x01 0x01 = 故障码, 0x1A = 环温26℃
+
+### 6.4 读取通道运行信息(CMD=0x05)
+
+**主控→电源:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x05 | 0x01 | 0x00 | 0x07 |
+
+**电源→主控:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x05 | 0x0D | 0x02 0x58 0x00 0x64 0x01 0x01 0x1A 0x55 0xF0 0x03 0xE8 0x08 0x98 | 0xBD |
+
+回复DATA解析:
+
+| 偏移 | 数据 | 含义 |
+|------|------|------|
+| 0-1 | 0x0258 | 设置电压 60.0V |
+| 2-3 | 0x0064 | 设置电流 10.0A |
+| 4-5 | 0x0101 | 故障码 |
+| 6 | 0x1A | 环温 26℃ |
+| 7-8 | 0x55F0 | 输入电压 220.00V |
+| 9-10 | 0x03E8 | 输入电流 10.00A |
+| 11-12 | 0x0898 | 输入功率 2200W |
+
+### 6.5 查询供应商信息(CMD=0x12)
+
+**主控→电源:**
+
+| ADDR | CHx | CMD | LEN | DATA | SUM |
+|------|-----|-----|-----|------|-----|
+| 0x01 | 0x00 | 0x12 | 0x01 | 0x00 | 0x14 |
+
+**电源→主控:**
+
+| ADDR | CHx | CMD | LEN | DATA |
+|------|-----|-----|-----|------|
+| 0x01 | 0x00 | 0x12 | 0x0F | 'YGCP1TN-A' + 硬件版本 + 软件版本 + 日期(年/月/日) + 序列号 |
+
+回复DATA解析:
+
+| 偏移 | 含义 |
+|------|------|
+| 0-1 | 厂商 ('YG') |
+| 2-9 | 电源型号 ('CP1TN-A') |
+| 10 | 硬件版本 (S1.0) |
+| 11 | 软件版本 (V1.0) |
+| 12-14 | 生产日期 (年/月/日) |
+| 15-16 | 序列号 |
diff --git a/docs/1TN协议标准_苏州翌工电源_V1.0.pdf b/docs/1TN协议标准_苏州翌工电源_V1.0.pdf
new file mode 100644
index 0000000..d5ae010
Binary files /dev/null and b/docs/1TN协议标准_苏州翌工电源_V1.0.pdf differ
diff --git a/docs/build_contract.js b/docs/build_contract.js
new file mode 100644
index 0000000..a1554de
--- /dev/null
+++ b/docs/build_contract.js
@@ -0,0 +1,272 @@
+// 开发合同.docx 生成脚本
+// 标准 Word 排版:A4、宋体、清晰的层级
+const {
+ Document, Packer, Paragraph, TextRun, Table, TableRow, TableCell,
+ AlignmentType, HeadingLevel, BorderStyle, WidthType, ShadingType,
+ PageNumber, Footer, Header, LevelFormat
+} = require('docx');
+const fs = require('fs');
+
+// 边框样式
+const noBorder = { style: BorderStyle.NONE, size: 0, color: "FFFFFF" };
+const tableBorders = {
+ top: { style: BorderStyle.SINGLE, size: 6, color: "000000" },
+ bottom: { style: BorderStyle.SINGLE, size: 6, color: "000000" },
+ left: { style: BorderStyle.SINGLE, size: 6, color: "000000" },
+ right: { style: BorderStyle.SINGLE, size: 6, color: "000000" },
+ insideHorizontal: { style: BorderStyle.SINGLE, size: 4, color: "000000" },
+ insideVertical: { style: BorderStyle.SINGLE, size: 4, color: "000000" },
+};
+
+// 标题段落(一级)
+function h1(text) {
+ return new Paragraph({
+ heading: HeadingLevel.HEADING_1,
+ spacing: { before: 360, after: 200 },
+ children: [new TextRun({ text, bold: true, size: 32, font: "SimSun" })],
+ });
+}
+
+// 标题段落(二级)
+function h2(text) {
+ return new Paragraph({
+ heading: HeadingLevel.HEADING_2,
+ spacing: { before: 240, after: 120 },
+ children: [new TextRun({ text, bold: true, size: 28, font: "SimSun" })],
+ });
+}
+
+// 正文段落
+function p(text, opts = {}) {
+ return new Paragraph({
+ spacing: { line: 360, before: 80, after: 80, lineRule: "auto" },
+ alignment: opts.align || AlignmentType.JUSTIFIED,
+ indent: opts.indent ? { firstLine: opts.indent } : undefined,
+ children: [new TextRun({ text, size: 24, font: "SimSun" })],
+ });
+}
+
+// 粗体段落
+function pBold(text, opts = {}) {
+ return new Paragraph({
+ spacing: { line: 360, before: 80, after: 80, lineRule: "auto" },
+ alignment: opts.align || AlignmentType.LEFT,
+ children: [new TextRun({ text, bold: true, size: 24, font: "SimSun" })],
+ });
+}
+
+// 空行
+function empty() {
+ return new Paragraph({ children: [new TextRun({ text: "" })] });
+}
+
+// 居中粗体大标题
+function bigTitle(text) {
+ return new Paragraph({
+ alignment: AlignmentType.CENTER,
+ spacing: { before: 200, after: 400 },
+ children: [new TextRun({ text, bold: true, size: 44, font: "SimHei" })],
+ });
+}
+
+// 表格单元格
+function cell(text, opts = {}) {
+ return new TableCell({
+ borders: tableBorders,
+ width: { size: opts.width || 2000, type: WidthType.DXA },
+ shading: opts.header ? { fill: "E7E6E6", type: ShadingType.CLEAR, color: "auto" } : undefined,
+ margins: { top: 100, bottom: 100, left: 120, right: 120 },
+ children: [new Paragraph({
+ alignment: opts.align || AlignmentType.LEFT,
+ spacing: { line: 300, before: 0, after: 0 },
+ children: [new TextRun({
+ text: String(text),
+ bold: !!opts.header,
+ size: 24,
+ font: "SimSun",
+ })],
+ })],
+ });
+}
+
+// 创建表格
+function makeTable(rows, colWidths) {
+ return new Table({
+ width: { size: colWidths.reduce((a, b) => a + b, 0), type: WidthType.DXA },
+ columnWidths: colWidths,
+ rows: rows.map((row, i) => new TableRow({
+ children: row.map((text, j) => cell(text, { width: colWidths[j], header: i === 0 })),
+ })),
+ });
+}
+
+// ==================== 内容 ====================
+
+const children = [];
+
+// 大标题
+children.push(bigTitle("36仓体直流电瓶车充电柜"));
+children.push(bigTitle("开发合同"));
+
+// 合同信息
+children.push(p("合同编号:_______________", { align: AlignmentType.LEFT }));
+children.push(p("签订地点:_______________", { align: AlignmentType.LEFT }));
+children.push(p("签订日期:______年______月______日", { align: AlignmentType.LEFT }));
+children.push(empty());
+
+// 第一条
+children.push(h1("第一条 合同双方"));
+children.push(pBold("甲方(委托方):"));
+children.push(p("公司名称:____________________________________"));
+children.push(p("法定代表人:________________"));
+children.push(p("地址:____________________________________"));
+children.push(p("联系人:________________ 联系电话:________________"));
+children.push(empty());
+children.push(pBold("乙方(开发方):"));
+children.push(p("公司名称:____________________________________"));
+children.push(p("法定代表人:________________"));
+children.push(p("地址:____________________________________"));
+children.push(p("联系人:________________ 联系电话:________________"));
+
+// 第二条
+children.push(h1("第二条 项目概述"));
+children.push(p("1. 项目名称:36仓体直流电瓶车充电柜开发项目。"));
+children.push(p("2. 项目目标:开发充电柜配套的柜控主板、仓控主板、嵌入式固件、后台管理系统及微信小程序。"));
+children.push(p("3. 开发周期:合同生效后12周。"));
+children.push(p("4. 交付成果:详见第五条及附件。"));
+
+// 第三条
+children.push(h1("第三条 开发内容"));
+children.push(makeTable(
+ [
+ ["序号", "开发内容", "详见附件"],
+ ["1", "柜控主板硬件设计开发", "附件一"],
+ ["2", "仓控主板硬件设计开发", "附件一"],
+ ["3", "嵌入式固件开发(柜控板+仓控板)", "附件一"],
+ ["4", "后台管理系统开发", "附件二"],
+ ["5", "微信小程序开发", "附件二"],
+ ["6", "通讯协议栈开发实现", "附件三"],
+ ],
+ [1000, 5360, 3000]
+));
+children.push(empty());
+children.push(p("乙方负责主板设计、元器件采购、焊接调试及联调支持。柜体、电源及外部器件由甲方提供。本合同不交付任何源代码。"));
+
+// 第四条
+children.push(h1("第四条 开发阶段"));
+children.push(makeTable(
+ [
+ ["阶段", "时间", "交付物"],
+ ["M1 方案定稿", "第1-2周", "原理图、PCB、BOM、协议文档"],
+ ["M2 开发完成", "第3-6周", "硬件样板、固件、后台、小程序"],
+ ["M3 联调完成", "第7-9周", "集成测试报告"],
+ ["M4 验收交付", "第10-12周", "完整成品、技术文档"],
+ ],
+ [2500, 2000, 4860]
+));
+children.push(empty());
+children.push(p("付款方式:______________________________________________________________________"));
+
+// 第五条
+children.push(h1("第五条 交付与验收"));
+children.push(p("交付物清单及验收标准详见附件三。整机验收时乙方到甲方现场完成联调,验收所需柜体及电源由甲方提供。"));
+
+// 第六条
+children.push(h1("第六条 知识产权"));
+children.push(p("1. 乙方在本合同履行过程中产生的全部技术成果(包括但不限于源代码、设计图纸、技术文档等)的知识产权归甲乙双方共同所有。"));
+children.push(p("2. 任何一方均有权在自身业务范围内自由使用上述技术成果,无需征得另一方同意或另行支付费用。"));
+
+// 第七条
+children.push(h1("第七条 保密条款"));
+children.push(p("1. 双方对合同履行过程中知悉的对方商业秘密、技术秘密负有保密义务。"));
+children.push(p("2. 保密期限自合同生效之日起3年。"));
+children.push(p("3. 未经对方书面同意,不得向第三方披露任何与本合同相关的信息。"));
+
+// 第八条
+children.push(h1("第八条 质保与维护"));
+children.push(p("1. 硬件质保期自验收通过之日起12个月,质保期内免费维修或更换。"));
+children.push(p("2. 软件质保期自验收通过之日起6个月,质保期内免费修复Bug。"));
+children.push(p("3. 质保期外的维护服务费用由双方另行协商。"));
+
+// 第九条
+children.push(h1("第九条 违约责任"));
+children.push(p("1. 甲方逾期付款的,每逾期一日按应付未付金额的千分之三支付违约金。"));
+children.push(p("2. 乙方逾期交付的,每逾期一日按合同总金额的千分之三支付违约金。"));
+children.push(p("3. 任何一方违反知识产权或保密条款的,应赔偿对方全部损失。"));
+
+// 第十条
+children.push(h1("第十条 合同总金额"));
+children.push(p("合同总金额:人民币____________元(大写:__________________________)。"));
+
+// 第十一条
+children.push(h1("第十一条 争议解决"));
+children.push(p("本合同履行过程中发生争议的,双方应协商解决;协商不成的,提交合同签订地人民法院诉讼解决。"));
+
+// 第十二条
+children.push(h1("第十二条 其他"));
+children.push(p("1. 本合同一式两份,甲乙双方各执一份,具有同等法律效力。"));
+children.push(p("2. 本合同附件为合同的组成部分,与本合同具有同等法律效力。"));
+children.push(p("3. 未尽事宜,双方可另行签订补充协议。"));
+
+// 签章区
+children.push(empty());
+children.push(empty());
+children.push(pBold("甲方(盖章):__________________________"));
+children.push(p("授权代表签字:________________________"));
+children.push(p("日期:______年______月______日"));
+children.push(empty());
+children.push(pBold("乙方(盖章):__________________________"));
+children.push(p("授权代表签字:________________________"));
+children.push(p("日期:______年______月______日"));
+
+// ==================== 生成文档 ====================
+
+const doc = new Document({
+ creator: "CodeBuddy",
+ title: "36仓体直流电瓶车充电柜开发合同",
+ styles: {
+ default: {
+ document: { run: { font: "SimSun", size: 24 } },
+ },
+ paragraphStyles: [
+ {
+ id: "Heading1", name: "Heading 1", basedOn: "Normal", next: "Normal", quickFormat: true,
+ run: { size: 32, bold: true, font: "SimHei" },
+ paragraph: { spacing: { before: 360, after: 200 }, outlineLevel: 0 },
+ },
+ {
+ id: "Heading2", name: "Heading 2", basedOn: "Normal", next: "Normal", quickFormat: true,
+ run: { size: 28, bold: true, font: "SimHei" },
+ paragraph: { spacing: { before: 240, after: 120 }, outlineLevel: 1 },
+ },
+ ],
+ },
+ sections: [{
+ properties: {
+ page: {
+ size: { width: 11906, height: 16838 }, // A4
+ margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 },
+ },
+ },
+ footers: {
+ default: new Footer({
+ children: [new Paragraph({
+ alignment: AlignmentType.CENTER,
+ children: [
+ new TextRun({ text: "第 ", size: 20, font: "SimSun" }),
+ new TextRun({ children: [PageNumber.CURRENT], size: 20, font: "SimSun" }),
+ new TextRun({ text: " 页 / 共 ", size: 20, font: "SimSun" }),
+ new TextRun({ children: [PageNumber.TOTAL_PAGES], size: 20, font: "SimSun" }),
+ new TextRun({ text: " 页", size: 20, font: "SimSun" }),
+ ],
+ })],
+ }),
+ },
+ children,
+ }],
+});
+
+Packer.toBuffer(doc).then(buffer => {
+ fs.writeFileSync("开发合同.docx", buffer);
+ console.log("OK: 开发合同.docx 已生成 (" + buffer.length + " bytes)");
+});
diff --git a/docs/convert.js b/docs/convert.js
new file mode 100644
index 0000000..ad58133
--- /dev/null
+++ b/docs/convert.js
@@ -0,0 +1,22 @@
+const marked = require('marked');
+const fs = require('fs');
+
+const files = ['开发合同.md', '附件一-硬件规格.md', '附件二-软件规格.md', '附件三-交付与验收标准.md'];
+const styles = `body{font-family:"Microsoft YaHei",sans-serif;max-width:900px;margin:40px auto;padding:20px;line-height:1.8;color:#333}
+h1{text-align:center;font-size:22px;border-bottom:2px solid #333;padding-bottom:10px}
+h2{font-size:18px;margin-top:30px;border-left:4px solid #333;padding-left:10px}
+h3{font-size:16px;margin-top:20px}h4{font-size:15px;margin-top:15px}
+table{width:100%;border-collapse:collapse;margin:15px 0;font-size:14px}
+th,td{border:1px solid #999;padding:8px;text-align:left}th{background:#f0f0f0}
+p,li{font-size:14px}hr{border:none;border-top:1px solid #ccc;margin:30px 0}
+pre{background:#f5f5f5;padding:15px;border-radius:4px;overflow-x:auto;font-size:13px}
+code{font-family:Consolas,monospace}
+.blank{color:#999;border-bottom:1px solid #999;display:inline-block;min-width:150px}
+.sign{margin-top:60px}.sign div{display:inline-block;width:45%;vertical-align:top}`;
+
+files.forEach(f => {
+ const md = fs.readFileSync(f, 'utf8');
+ const html = `
${f.replace('.md','')}${marked.parse(md)}`;
+ fs.writeFileSync(f.replace('.md', '.html'), html);
+ console.log(`OK: ${f} -> ${f.replace('.md', '.html')}`);
+});
diff --git a/docs/package-lock.json b/docs/package-lock.json
new file mode 100644
index 0000000..0a20dc9
--- /dev/null
+++ b/docs/package-lock.json
@@ -0,0 +1,548 @@
+{
+ "name": "docs",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "docx": "^9.7.1",
+ "marked": "^18.0.5",
+ "puppeteer": "^25.1.0"
+ }
+ },
+ "node_modules/@puppeteer/browsers": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.0.4.tgz",
+ "integrity": "sha512-HGM8iAmGTf+Y7t0373szVbTmt3d7vPkYL/1bpOkOFO0YUYLgSeuYBCzESklogNPvOBnZ/MRD5f07OkpqH1trtA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "modern-tar": "^0.7.6",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "browsers": "lib/main-cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ },
+ "peerDependencies": {
+ "proxy-agent": ">=8.0.1"
+ },
+ "peerDependenciesMeta": {
+ "proxy-agent": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "25.9.4",
+ "resolved": "https://registry.npmmirror.com/@types/node/-/node-25.9.4.tgz",
+ "integrity": "sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/chromium-bidi": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
+ "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^3.24.1"
+ },
+ "engines": {
+ "node": ">=20.19.0 <22.0.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
+ "node_modules/cliui": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
+ "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.1",
+ "wrap-ansi": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "license": "MIT"
+ },
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1624250",
+ "resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1624250.tgz",
+ "integrity": "sha512-YFAat/lOiIk0ARmBweG+ygrEcbZrq5B9urRyUoeQKp53MlidHXE2TmTbxKcaXoQj7u/aX+jebDO4BW55rs0WwA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/docx": {
+ "version": "9.7.1",
+ "resolved": "https://registry.npmmirror.com/docx/-/docx-9.7.1.tgz",
+ "integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "^25.2.3",
+ "hash.js": "^1.1.7",
+ "jszip": "^3.10.1",
+ "nanoid": "^5.1.3",
+ "xml": "^1.0.1",
+ "xml-js": "^1.6.8"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/get-caller-file": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
+ "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+ "license": "ISC",
+ "engines": {
+ "node": "6.* || 8.* || >= 10.*"
+ }
+ },
+ "node_modules/hash.js": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmmirror.com/hash.js/-/hash.js-1.1.7.tgz",
+ "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "minimalistic-assert": "^1.0.1"
+ }
+ },
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/marked": {
+ "version": "18.0.5",
+ "resolved": "https://registry.npmmirror.com/marked/-/marked-18.0.5.tgz",
+ "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==",
+ "license": "MIT",
+ "bin": {
+ "marked": "bin/marked.js"
+ },
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "license": "ISC"
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/modern-tar": {
+ "version": "0.7.6",
+ "resolved": "https://registry.npmmirror.com/modern-tar/-/modern-tar-0.7.6.tgz",
+ "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "5.1.15",
+ "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-5.1.15.tgz",
+ "integrity": "sha512-kBg3RpGtIe+RpTbyXwoI6pk5yD7KUiI3sygUqgeBMRst42KmhB4RZC7eiO9Wa1HIpaCCtpE2DJ6OI4Wi5ebwFw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ }
+ },
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
+ "node_modules/puppeteer": {
+ "version": "25.1.0",
+ "resolved": "https://registry.npmmirror.com/puppeteer/-/puppeteer-25.1.0.tgz",
+ "integrity": "sha512-7L6/0JM7XStK99lIL4xQySyNEXNfII6pk0BxkI5kKBTOhR7AsoQiv067YTsE/rIXxQiq9ajlO4WcqBjS/FWK1A==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "3.0.4",
+ "chromium-bidi": "16.0.1",
+ "devtools-protocol": "0.0.1624250",
+ "lilconfig": "^3.1.3",
+ "puppeteer-core": "25.1.0",
+ "typed-query-selector": "^2.12.2"
+ },
+ "bin": {
+ "puppeteer": "lib/puppeteer/node/cli.js"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "25.1.0",
+ "resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.1.0.tgz",
+ "integrity": "sha512-jKzy5y4WG6uNuFbTWgW1D7mqoT9o0nllc/6a1DGF775T1mPmgw3scdFEtEq67yVFikavQmbYq6NLfbTfxHSlqQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "3.0.4",
+ "chromium-bidi": "16.0.1",
+ "devtools-protocol": "0.0.1624250",
+ "typed-query-selector": "^2.12.2",
+ "webdriver-bidi-protocol": "0.4.2",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/require-directory": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
+ "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/sax": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz",
+ "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=11.0.0"
+ }
+ },
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
+ "node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/typed-query-selector": {
+ "version": "2.12.2",
+ "resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
+ "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
+ "license": "MIT"
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "license": "MIT"
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "node_modules/webdriver-bidi-protocol": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
+ "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+ "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.0",
+ "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz",
+ "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xml": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmmirror.com/xml/-/xml-1.0.1.tgz",
+ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
+ "license": "MIT"
+ },
+ "node_modules/xml-js": {
+ "version": "1.6.11",
+ "resolved": "https://registry.npmmirror.com/xml-js/-/xml-js-1.6.11.tgz",
+ "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
+ "license": "MIT",
+ "dependencies": {
+ "sax": "^1.2.4"
+ },
+ "bin": {
+ "xml-js": "bin/cli.js"
+ }
+ },
+ "node_modules/y18n": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
+ "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/yargs": {
+ "version": "17.7.2",
+ "resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
+ "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^8.0.1",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.3",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^21.1.1"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/yargs-parser": {
+ "version": "21.1.1",
+ "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
+ "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/docs/package.json b/docs/package.json
new file mode 100644
index 0000000..94c34e3
--- /dev/null
+++ b/docs/package.json
@@ -0,0 +1,7 @@
+{
+ "dependencies": {
+ "docx": "^9.7.1",
+ "marked": "^18.0.5",
+ "puppeteer": "^25.1.0"
+ }
+}
diff --git a/docs/toPdf.js b/docs/toPdf.js
new file mode 100644
index 0000000..7c71c23
--- /dev/null
+++ b/docs/toPdf.js
@@ -0,0 +1,26 @@
+const puppeteer = require('puppeteer');
+const fs = require('fs');
+const path = require('path');
+
+const dir = 'D:\\projects\\charging-cabinet\\docs';
+
+async function convert(htmlName, pdfName) {
+ const html = fs.readFileSync(path.join(dir, htmlName), 'utf8');
+ const browser = await puppeteer.launch({ headless: 'new', executablePath: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', args: ['--no-sandbox'] });
+ const page = await browser.newPage();
+ await page.setContent(html, { waitUntil: 'networkidle0' });
+ await page.pdf({
+ path: path.join(dir, pdfName),
+ format: 'A4',
+ margin: { top: '20mm', bottom: '20mm', left: '20mm', right: '20mm' },
+ printBackground: true,
+ });
+ await browser.close();
+ console.log(`OK: ${pdfName}`);
+}
+
+(async () => {
+ await convert('开发合同.html', '开发合同.pdf');
+ await convert('附件-开发需求规格说明书.html', '附件-开发需求规格说明书.pdf');
+ console.log('Done');
+})();
diff --git a/docs/开发合同-充电柜-KF-06062401.docx b/docs/开发合同-充电柜-KF-06062401.docx
new file mode 100644
index 0000000..138efcf
Binary files /dev/null and b/docs/开发合同-充电柜-KF-06062401.docx differ
diff --git a/docs/开发合同.html b/docs/开发合同.html
new file mode 100644
index 0000000..08e3607
--- /dev/null
+++ b/docs/开发合同.html
@@ -0,0 +1,161 @@
+开发合同36仓体直流电瓶车充电柜 开发合同
+合同编号: ________________
+签订地点: ________________
+签订日期: _____年____月____日____
+
+第一条 合同双方
+甲方(委托方):
+
+- 公司名称:______________________________
+- 法定代表人:________________
+- 地址:______________________________
+- 联系人:________ 联系电话:____________
+
+乙方(开发方):
+
+- 公司名称:______________________________
+- 法定代表人:________________
+- 地址:______________________________
+- 联系人:________ 联系电话:____________
+
+
+第二条 项目概述
+
+- 项目名称:36仓体直流电瓶车充电柜开发项目
+- 项目目标:开发充电柜配套的柜控主板、仓控主板、嵌入式固件、后台管理系统及微信小程序
+- 开发周期:合同生效后12周
+- 交付成果:详见第五条及附件
+
+第三条 开发内容
+
+
+
+| 序号 |
+开发内容 |
+详见附件 |
+
+
+
+| 1 |
+柜控主板硬件设计开发 |
+附件一 |
+
+
+| 2 |
+仓控主板硬件设计开发 |
+附件一 |
+
+
+| 3 |
+嵌入式固件开发(柜控板+仓控板) |
+附件一 |
+
+
+| 4 |
+后台管理系统开发 |
+附件二 |
+
+
+| 5 |
+微信小程序开发 |
+附件二 |
+
+
+| 6 |
+通讯协议栈开发实现 |
+附件三 |
+
+
+乙方负责主板设计、元器件采购、焊接调试及联调支持。柜体、电源及外部器件由甲方提供。本合同不交付任何源代码。
+第四条 开发阶段
+
+
+
+| 阶段 |
+时间 |
+交付物 |
+
+
+
+| M1 方案定稿 |
+第1-2周 |
+原理图、PCB、BOM、协议文档 |
+
+
+| M2 开发完成 |
+第3-6周 |
+硬件样板、固件、后台、小程序 |
+
+
+| M3 联调完成 |
+第7-9周 |
+集成测试报告 |
+
+
+| M4 验收交付 |
+第10-12周 |
+完整成品、技术文档 |
+
+
+付款方式: ________________________________________________________________________
+第五条 交付与验收
+交付物清单及验收标准详见附件三。整机验收时乙方到甲方现场完成联调,验收所需柜体及电源由甲方提供。
+
+第六条 知识产权
+
+- 乙方在本合同履行过程中产生的全部技术成果(包括但不限于源代码、设计图纸、技术文档等)的知识产权归甲乙双方共同所有。
+- 任何一方均有权在自身业务范围内自由使用上述技术成果,无需征得另一方同意或另行支付费用。
+
+
+第七条 保密条款
+
+- 双方对合同履行过程中知悉的对方商业秘密、技术秘密负有保密义务。
+- 保密期限自合同生效之日起3年。
+- 未经对方书面同意,不得向第三方披露任何与本合同相关的信息。
+
+
+第八条 质保与维护
+
+- 硬件质保期自验收通过之日起 12个月,质保期内免费维修或更换。
+- 软件质保期自验收通过之日起 6个月,质保期内免费修复Bug。
+- 质保期外的维护服务费用由双方另行协商。
+
+
+第九条 违约责任
+
+- 甲方逾期付款的,每逾期一日按应付未付金额的千分之三支付违约金。
+- 乙方逾期交付的,每逾期一日按合同总金额的千分之三支付违约金。
+- 任何一方违反知识产权或保密条款的,应赔偿对方全部损失。
+
+
+第十条 合同总金额
+合同总金额:人民币 ___________ 元(大写:____________________)
+
+第十一条 争议解决
+本合同履行过程中发生争议的,双方应协商解决;协商不成的,提交合同签订地人民法院诉讼解决。
+
+第十二条 其他
+
+- 本合同一式两份,甲乙双方各执一份,具有同等法律效力。
+- 本合同附件为合同的组成部分,与本合同具有同等法律效力。
+- 未尽事宜,双方可另行签订补充协议。
+
+
+甲方(盖章): ____________________
+授权代表签字:________________
+日期:_____年____月____日
+
+乙方(盖章): ____________________
+授权代表签字:________________
+日期:_____年____月____日
+
\ No newline at end of file
diff --git a/docs/开发合同.md b/docs/开发合同.md
new file mode 100644
index 0000000..3d4e386
--- /dev/null
+++ b/docs/开发合同.md
@@ -0,0 +1,127 @@
+# 36仓体直流电瓶车充电柜 开发合同
+
+**合同编号:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+**签订地点:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+**签订日期:** \_\_\_\_\_年\_\_\_\_月\_\_\_\_日\_\_\_\_
+
+---
+
+## 第一条 合同双方
+
+**甲方(委托方):**
+- 公司名称:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+- 法定代表人:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+- 地址:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+- 联系人:\_\_\_\_\_\_\_\_ 联系电话:\_\_\_\_\_\_\_\_\_\_\_\_
+
+**乙方(开发方):**
+- 公司名称:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+- 法定代表人:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+- 地址:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+- 联系人:\_\_\_\_\_\_\_\_ 联系电话:\_\_\_\_\_\_\_\_\_\_\_\_
+
+---
+
+## 第二条 项目概述
+
+1. **项目名称**:36仓体直流电瓶车充电柜开发项目
+2. **项目目标**:开发充电柜配套的柜控主板、仓控主板、嵌入式固件、后台管理系统及微信小程序
+3. **开发周期**:合同生效后12周
+4. **交付成果**:详见第五条及附件
+
+## 第三条 开发内容
+
+| 序号 | 开发内容 | 详见附件 |
+|------|---------|---------|
+| 1 | 柜控主板硬件设计开发 | 附件一 |
+| 2 | 仓控主板硬件设计开发 | 附件一 |
+| 3 | 嵌入式固件开发(柜控板+仓控板) | 附件一 |
+| 4 | 后台管理系统开发 | 附件二 |
+| 5 | 微信小程序开发 | 附件二 |
+| 6 | 通讯协议栈开发实现 | 附件三 |
+
+乙方负责主板设计、元器件采购、焊接调试及联调支持。柜体、电源及外部器件由甲方提供。**本合同不交付任何源代码。**
+
+## 第四条 开发阶段
+
+| 阶段 | 时间 | 交付物 |
+|------|------|--------|
+| M1 方案定稿 | 第1-2周 | 原理图、PCB、BOM、协议文档 |
+| M2 开发完成 | 第3-6周 | 硬件样板、固件、后台、小程序 |
+| M3 联调完成 | 第7-9周 | 集成测试报告 |
+| M4 验收交付 | 第10-12周 | 完整成品、技术文档 |
+
+**付款方式:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+## 第五条 交付与验收
+
+交付物清单及验收标准详见附件三。整机验收时乙方到甲方现场完成联调,验收所需柜体及电源由甲方提供。
+
+---
+
+## 第六条 知识产权
+
+1. 乙方在本合同履行过程中产生的全部技术成果(包括但不限于源代码、设计图纸、技术文档等)的知识产权归**甲乙双方共同所有**。
+2. 任何一方均有权在自身业务范围内自由使用上述技术成果,无需征得另一方同意或另行支付费用。
+
+---
+
+## 第七条 保密条款
+
+1. 双方对合同履行过程中知悉的对方商业秘密、技术秘密负有保密义务。
+2. 保密期限自合同生效之日起3年。
+3. 未经对方书面同意,不得向第三方披露任何与本合同相关的信息。
+
+---
+
+## 第八条 质保与维护
+
+1. 硬件质保期自验收通过之日起 **12个月**,质保期内免费维修或更换。
+2. 软件质保期自验收通过之日起 **6个月**,质保期内免费修复Bug。
+3. 质保期外的维护服务费用由双方另行协商。
+
+---
+
+## 第九条 违约责任
+
+1. 甲方逾期付款的,每逾期一日按应付未付金额的千分之三支付违约金。
+2. 乙方逾期交付的,每逾期一日按合同总金额的千分之三支付违约金。
+3. 任何一方违反知识产权或保密条款的,应赔偿对方全部损失。
+
+---
+
+## 第十条 合同总金额
+
+**合同总金额**:人民币 \_\_\_\_\_\_\_\_\_\_\_ 元(大写:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_)
+
+---
+
+## 第十一条 争议解决
+
+本合同履行过程中发生争议的,双方应协商解决;协商不成的,提交合同签订地人民法院诉讼解决。
+
+---
+
+## 第十二条 其他
+
+1. 本合同一式两份,甲乙双方各执一份,具有同等法律效力。
+2. 本合同附件为合同的组成部分,与本合同具有同等法律效力。
+3. 未尽事宜,双方可另行签订补充协议。
+
+---
+
+**甲方(盖章):** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+授权代表签字:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+日期:\_\_\_\_\_年\_\_\_\_月\_\_\_\_日
+
+---
+
+**乙方(盖章):** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+授权代表签字:\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+日期:\_\_\_\_\_年\_\_\_\_月\_\_\_\_日
diff --git a/docs/开发合同.pdf b/docs/开发合同.pdf
new file mode 100644
index 0000000..5c6a5b8
Binary files /dev/null and b/docs/开发合同.pdf differ
diff --git a/docs/开发文档.md b/docs/开发文档.md
new file mode 100644
index 0000000..b11306f
--- /dev/null
+++ b/docs/开发文档.md
@@ -0,0 +1,935 @@
+# 36仓体直流电瓶车充电柜 - 开发文档
+
+## 1. 项目概述
+
+### 1.1 项目目标
+
+开发一款36仓体直流电瓶车充电柜,配置6个电源(每个电源对应6个仓体的独立直流输出口),支持每个仓体输出口独立设置电流、电压参数,适配星恒BMS Modbus协议与电源1TN RS485协议,实现安全、高效的独立充电功能。每个仓体配套状态指示灯,通过不同灯光状态反馈仓体工作情况,同时支持平台远程禁用仓体。
+
+核心新增定制仓控主板及柜控主板功能:
+- 每个仓控主板单独与所属电源通讯
+- 仓控板采用拨码开关设置ID(ID范围1-6)
+- 柜控板ID固定为128
+
+### 1.2 核心功能矩阵
+
+| 功能模块 | 子功能 | 说明 |
+|---------|--------|------|
+| 独立充电 | 多电压适配 | 48V/60V/72V直流适配,电流5A-20A可调 |
+| | 快充模式 | 直流快充,1-2小时充满 |
+| | 充电控制 | 扫码开门/预约/定时充电 |
+| | BMS协议适配 | 支持星恒485一线通、天能485,平台可配置 |
+| | 无协议充电 | 固定电压电流输出,低电流自动停止 |
+| 仓体状态指示 | 绿灯闪烁 | 充电中,继电器闭合,电源正常输出 |
+| | 绿灯常亮 | 仓体空闲,可正常启动充电 |
+| | 红灯常亮 | 平台仓体禁用,管理员锁定 |
+| | 红灯闪烁 | 电池不符合要求或设备告警或门未关 |
+| 门锁控制 | 扫码开门 | 用户扫码→平台验证→下发开门指令 |
+| | 门磁检测 | 实时监测仓门开/关状态 |
+| | 关门超时报警 | 超过设置阈值未关门,上传告警(阈值可配置) |
+| 平台仓体控制 | 远程禁用 | 管理员下发禁用指令,强制锁定仓体 |
+| | 状态同步 | 禁用后指示灯切换,禁止充电 |
+| | 手动解除 | 管理员解除禁用,恢复空闲状态 |
+| 协议对接 | 1TN RS485 | 仓控板与电源通讯(电源参数控制) |
+| | 星恒BMS Modbus | 仓控板与星恒电池BMS通讯(状态监测) |
+| | 天能485 | 仓控板与天能电池BMS通讯(状态监测) |
+| | 仓控-柜控485 | 仓控板(ID1-6)与柜控板(ID128)通讯 |
+| 安全防护 | 过充/过温/过流/短路/漏电 | 实时监测,自动断电 |
+| | 烟感+喷淋 | 烟感触发→断开接触器+打开喷淋电磁阀 |
+| | 浸水保护 | 浸水触发→断开接触器切断380V |
+| | 远程恢复 | 误报警可平台远程吸合接触器恢复供电 |
+| | 6路温控 | 仓体温度实时采集,高温阈值可配置 |
+| | 加热风扇 | 环境温度<0°C自动启动加热,≥25°C停止 |
+| 固件管理 | OTA升级 | 平台远程升级柜控板和仓控板固件 |
+| 定制主板 | 仓控主板 | 6路继电器、门锁驱动、门磁检测、光耦隔离、多通道485、温控 |
+| | 柜控主板 | 烟感/浸水接口、喷淋电磁阀、加热风扇、电磁泵、4G模块(TCP长连接) |
+
+## 2. 系统架构
+
+### 2.1 硬件架构
+
+```
+ ┌───────────────────────┐
+ │ 380V 市电输入 │
+ └──────────┬────────────┘
+ │
+ ┌────┴────┐
+ │ 漏保 │
+ └────┬────┘
+ │
+ ┌──────────┼──────────┐
+ │ │ │
+ ┌─────┴─────┐ ┌──┴───┐ ┌───┴──────┐
+ │ 12V电源 │ │接触器 │ │其他负载 │
+ │(预接触器) │ │柜控板控│ │(风扇等) │
+ └─────┬─────┘ └──┬───┘ └──────────┘
+ │ │
+ ┌─────┴─────┐ ┌──┴───────────┐
+ │ 柜控板128 │ │ 充电电源×6 │
+ │ 仓控板×6 │ │ (直流输出) │
+ │ (常供电) │ └──────────────┘
+ └─────┬─────┘
+ │
+ ┌─────┴─────┐
+ │ 后备电池 │
+ │(380V断电备用)│
+ └───────────┘
+```
+
+### 2.2 软件架构
+
+```
+┌─────────────────────────────────────────────┐
+│ 后台管理平台 │
+│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │
+│ │设备管理│ │用户管理│ │数据统计│ │故障管理 │ │
+│ │OTA升级│ │ │ │ │ │报警阈值 │ │
+│ └──┬───┘ └──┬───┘ └──┬───┘ └────┬─────┘ │
+│ ┌──┴──────────┴──────────┴──────────┴────┐ │
+│ │ BMS协议配置 · 无协议充电参数 │ │
+│ │ 门锁控制 · 仓体控制 · 仓体禁用 │ │
+│ └─────────────────────────────────────────┘ │
+│ ┌────────────────────────────────────────┐ │
+│ │ 自定义TCP通讯服务(长连接) │ │
+│ └────────────────────────────────────────┘ │
+├─────────────────────────────────────────────┤
+│ 微信小程序 │
+│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │
+│ │扫码充电│ │预约充电│ │进度查询│ │消息通知 │ │
+│ │扫码开门│ │ │ │ │ │ │ │
+│ └──────┘ └──────┘ └──────┘ └──────────┘ │
+├─────────────────────────────────────────────┤
+│ 柜控主板(ID128)固件 │
+│ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
+│ │ TCP通讯 │ │数据处理 │ │充电控制逻辑 │ │
+│ ├──────────┤ ├──────────┤ ├─────────────┤ │
+│ │烟感/浸水 │ │485主站 │ │门锁指令转发 │ │
+│ │喷淋电磁阀 │ │OTA管理 │ │BMS配置管理 │ │
+│ └──────────┘ └──────────┘ └─────────────┘ │
+├─────────────────────────────────────────────┤
+│ 仓控主板(ID1-6)固件 │
+│ ┌──────────┐ ┌──────────┐ ┌─────────────┐ │
+│ │ 485从站 │ │BMS通讯 │ │ 继电器控制 │ │
+│ │ │ │多协议适配 │ │ 门锁控制 │ │
+│ ├──────────┤ ├──────────┤ ├─────────────┤ │
+│ │指示灯控制 │ │温控采集 │ │ 门磁监测 │ │
+│ │OTA升级 │ │ │ │ 数据上报 │ │
+│ └──────────┘ └──────────┘ └─────────────┘ │
+└─────────────────────────────────────────────┘
+```
+
+## 3. 通讯协议设计
+
+### 3.1 电源 1TN RS485 协议
+
+#### 3.1.1 协议基础
+
+- 标准号:YG20240118-1,版本 V1.0
+- 制定方:苏州翌工电源科技有限公司
+- 适用设备:换电柜用锂电池充电器(1TN系列多路充电器)
+- 传输模式:Modbus-RTU
+- 波特率:9600
+- 字格式:11位:1个起始位(0) + 8个数据位 + 1个停止位
+- 通讯方式:半双工主从应答(仓控板为主机,电源为从机)
+- 地址范围:0x01-0x07(对应电源3位拨码开关,001=0x01 ... 111=0x07)
+- 校验方式:和校验(SUM = 所有字节相加取低8位)
+- 字节序:BigEndian(网络字节序)
+
+#### 3.1.2 信息帧格式
+
+| 字段 | 字节数 | 说明 |
+|------|--------|------|
+| ADDR | 1 | 电源地址 (0x01-0x07,对应拨码开关) |
+| CHx | 1 | 通道号,高7位通道编号,Bit0主/副通道 |
+| CMD | 1 | 功能码 |
+| LEN | 1 | 数据区长度 |
+| DATA | LEN | 数据区,BigEndian |
+| SUM | 1 | 和校验 = (ADDR+CHx+CMD+LEN+DATA) & 0xFF |
+
+**CHx 通道字节解析:**
+
+| 通道 | Bit1-7 | CHx值(Bit0=0) | CHx值(Bit0=1) |
+|------|--------|---------------|---------------|
+| CH1 | 0x00 | 0x00 (主通道) | 0x01 (副通道) |
+| CH2 | 0x01 | 0x02 (主通道) | 0x03 (副通道) |
+| CH3 | 0x02 | 0x04 (主通道) | 0x05 (副通道) |
+| CH4 | 0x03 | 0x06 (主通道) | 0x07 (副通道) |
+| CH5 | 0x04 | 0x08 (主通道) | 0x09 (副通道) |
+| CH6 | 0x05 | 0x0A (主通道) | 0x0B (副通道) |
+
+#### 3.1.3 功能码定义
+
+| 序号 | 命令 | CMD | LEN | 数据区 |
+|------|------|-----|-----|--------|
+| 1 | 设置通道输出电压/电流 | 0x02 | 0x04 | 电压(2B mV) + 电流(2B 0.1A) |
+| 2 | 设置通道开关机 | 0x03 | 0x01 | 0x00=关机, 0x01=开机 |
+| 3 | 通道组合指令(开关机+调压+调流) | 0x04 | 0x05 | 开关(1B) + 电压(2B) + 电流(2B) |
+| 4 | 电源通道信息查询 | 0x05 | 0x01 | 查询类型 |
+| 5 | 供应商/型号/版本号/序列号查询 | 0x12 | 0x01 | 查询类型 |
+| 6 | 升级状态查询 | 0x18 | - | 详见升级协议 |
+| 7 | OTA远程升级 | 0x19 | - | 详见升级协议 |
+
+**电源回复数据格式:**
+
+| 指令 | 回复LEN | 回复DATA说明 |
+|------|---------|-------------|
+| 0x02设置V/I | 0x01 | 0x01=设置成功 |
+| 0x03开关机 | 0x01 | 0x01=设置成功 |
+| 0x04组合指令 | 0x07 | 电压(2B) + 电流(2B) + 故障码(2B) + 环温(1B 0.1℃) |
+| 0x05查询 | 0x0D | 设置电压(2B) + 设置电流(2B) + 故障码(2B) + 环温(1B) + 输入电压(2B 0.01V) + 输入电流(2B 0.01A) + 输入功率(2B W) |
+| 0x12供应商信息 | 0x0F | 厂商(2B ASCII) + 型号(8B ASCII) + 硬件版本(1B) + 软件版本(1B) + 生产日期(3B) + 序列号(2B) |
+
+#### 3.1.4 故障码定义
+
+**故障字节1 (设备状态):**
+
+| Bit | 含义 | Bit | 含义 |
+|-----|------|-----|------|
+| Bit0 | 输入欠压 | Bit4 | 输出欠压 |
+| Bit1 | 输入过压 | Bit5 | 输出短路 |
+| Bit2 | NC | Bit6 | 输出过流 |
+| Bit3 | 输出过压 | Bit7 | 过温 |
+
+**故障字节2 (系统状态):**
+
+| Bit | 含义 | Bit | 含义 |
+|-----|------|-----|------|
+| Bit0 | 硬件故障 | Bit4 | NC |
+| Bit1 | PFC故障 | Bit5 | NC |
+| Bit2 | 风扇故障 | Bit6 | NC |
+| Bit3 | 电池反接故障 | Bit7 | NC |
+
+#### 3.1.5 指令示例
+
+```
+设置1#电源 CH1 开启+60.0V+10.0A (组合指令 0x04):
+ 主控 → 电源: ADDR=0x01, CHx=0x00, CMD=0x04, LEN=0x05,
+ DATA=[0x01, 0x02, 0x58, 0x00, 0x64],
+ SUM=(0x01+0x00+0x04+0x05+0x01+0x02+0x58+0x00+0x64)=0xC9
+ 电源 → 主控: ADDR=0x01, CHx=0x00, CMD=0x04, LEN=0x07,
+ DATA=[0x02,0x58, 0x00,0x64, 0x01,0x01, 0x1A],
+ 回复: 当前电压60.0V, 电流10.0A, 故障码0x0101, 环温26℃
+
+读取1#电源 CH1 运行信息 (查询 0x05):
+ 主控 → 电源: ADDR=0x01, CHx=0x00, CMD=0x05, LEN=0x01, DATA=[0x00], SUM=0x07
+ 电源 → 主控: ADDR=0x01, CHx=0x00, CMD=0x05, LEN=0x0D,
+ DATA=[0x02,0x58, 0x00,0x64, 0x01,0x01, 0x1A,
+ 0x55,0xF0, 0x03,0xE8, 0x08,0x98],
+ 回复: 设定60.0V/10.0A, 故障码0x0101, 环温26℃,
+ 输入电压220.00V, 输入电流10.00A, 输入功率2200W
+
+设置2#电源 CH2 关机 (开关机 0x03):
+ 主控 → 电源: ADDR=0x02, CHx=0x02, CMD=0x03, LEN=0x01, DATA=[0x00], SUM=0x08
+
+供应商信息查询 (0x12):
+ 主控 → 电源: ADDR=0x01, CHx=0x00, CMD=0x12, LEN=0x01, DATA=[0x00], SUM=0x14
+ 电源 → 主控: ADDR=0x01, CHx=0x00, CMD=0x12, LEN=0x0F,
+ DATA='YGCP1TN-A' + 硬件版本(1B) + 软件版本(1B) +
+ 生产日期(3B: 年/月/日) + 序列号(2B)
+```
+
+### 3.2 BMS 协议适配
+
+充电柜支持多协议BMS适配,通过平台远程配置当前柜体使用的BMS协议类型。仓控板根据配置选择对应的BMS通讯协议进行电池数据读取。
+
+#### 3.2.1 BMS协议类型配置
+
+| 协议类型 | 说明 | 配置方式 |
+|---------|------|---------|
+| 星恒485一线通 | 标准Modbus-RTU,地址0x16 | 平台配置 |
+| 天能485 | 自定义协议 | 平台配置 |
+| 无协议充电 | 不读取BMS,固定电压电流输出 | 平台配置 |
+
+BMS协议配置存储在柜控板Flash中,仓控板上电时从柜控板获取当前BMS协议类型。平台可远程切换协议类型,切换后下次充电生效。
+
+#### 3.2.2 星恒 BMS Modbus 协议
+
+**协议基础:**
+
+- 版本:V1.1
+- 传输模式:Modbus-RTU
+- 波特率:9600,数据位8,停止位1,无校验
+- BMS固定地址:0x16 (16H)
+- 支持功能码:03H(读保持寄存器)、10H(写控制指令)
+
+**核心寄存器映射:**
+
+| 寄存器地址 | 参数 | 类型 | 单位 | 说明 |
+|-----------|------|------|------|------|
+| 10000-10002 | BMS软硬件版本/控制 | 只读/读写 | - | 10002: 充电禁止/允许 |
+| 10003-10027 | 电池条码 | 只读 | ASCII | 25个寄存器 |
+| 10036 | 电芯材料 | 只读 | - | 磷酸铁锂/三元锂/锰酸锂 |
+| 10043-10072 | 30串单体电压 | 只读 | mV | 30个寄存器 |
+| 10074 | 总电压 | 只读 | mV | 1个寄存器 |
+| 10080-10089 | 10路电芯温度 | 只读 | 0.1℃ | 10个寄存器 |
+| 10091 | 单体过压告警 | 只读 | BIT | bit0=告警 |
+| 10092 | 单体欠压告警 | 只读 | BIT | bit0=告警 |
+| 10094 | 电芯不平衡告警 | 只读 | BIT | bit0=告警 |
+| 10095 | 过流告警 | 只读 | BIT | bit0=告警 |
+| 10096 | 过温告警 | 只读 | BIT | bit0=告警 |
+| 10097 | 低温告警 | 只读 | BIT | bit0=告警 |
+| 10101 | SOH | 只读 | 0.01 | 健康度0-1 |
+| 10102 | SOC | 只读 | 0.1% | 剩余电量 |
+| 10103 | 充电请求电压 | 只读 | mV | BMS请求的充电电压 |
+| 10104 | 最大充电请求电流 | 只读 | 0.1A | BMS请求的最大电流 |
+| 10156 | 电池组唯一编码 | 只读 | - | - |
+| 10166 | 总压异常告警 | 只读 | BIT | bit0=告警 |
+| 10167 | 容量通量 | 只读 | - | - |
+| 10169 | 能量通量 | 只读 | - | - |
+| 10172 | 生产日期 | 只读 | - | 年/月/日 |
+
+#### 3.2.3 天能 485 协议
+
+**协议基础:**
+
+- 传输模式:自定义RS485协议
+- 波特率:9600,数据位8,停止位1,无校验
+- BMS地址:0x01
+- 协议细节:以天能电池官方提供的BMS通讯协议文档为准,仓控板固件中预留协议适配层,支持按配置切换协议实现
+
+**适配策略:**
+
+仓控板BMS通讯模块采用分层设计:
+1. **协议抽象层**:统一的数据接口(电压/电流/SOC/温度/告警等)
+2. **协议实现层**:星恒Modbus实现、天能自定义协议实现
+3. **配置选择层**:根据平台下发的BMS类型选择对应的协议实现
+
+#### 3.2.4 无协议充电模式
+
+当配置为无协议充电模式时,仓控板不通过485_2总线读取BMS数据,充电过程完全由平台下发的固定参数控制:
+
+- 平台下发固定充电电压、充电电流
+- 仓控板直接控制电源输出设定值
+- 通过监测电源输出电流判断是否充满:电流低于设定阈值时自动停止
+- 低电流阈值由平台配置,可在充电过程中动态调整
+
+### 3.3 仓控主板 ↔ 柜控主板 485 通讯
+
+#### 3.3.1 通讯参数
+
+- 波特率:9600
+- 半双工主从方式
+- 柜控主板(ID128)为主站
+- 仓控主板(ID1-6)为从站
+- 光耦隔离保护
+- 通讯响应延迟 ≤ 0.5s
+
+#### 3.3.2 通讯帧结构
+
+自定义协议帧:
+
+| 字段 | 字节数 | 说明 |
+|------|--------|------|
+| 帧头 | 2 | 0xAA 0x55 |
+| 目标ID | 1 | 0x01-0x06 或 0x80(柜控板) |
+| 源ID | 1 | 0x01-0x06 或 0x80 |
+| 功能码 | 1 | 指令类型 |
+| 数据长度 | 2 | BigEndian |
+| 数据区 | N | 具体数据 |
+| CRC16 | 2 | Modbus CRC16 |
+
+#### 3.3.3 功能码定义
+
+| 功能码 | 指令 | 方向 | 说明 |
+|--------|------|------|------|
+| 0x01 | 上传BMS数据 | 仓控→柜控 | 电池SOC/电压/温度/告警/BMS协议类型 |
+| 0x02 | 上传温控数据 | 仓控→柜控 | 6路仓体温度+环境温度 |
+| 0x03 | 上传电源状态 | 仓控→柜控 | 电源运行参数 |
+| 0x04 | 上传设备状态 | 仓控→柜控 | 继电器/通讯/门磁/门锁状态 |
+| 0x05 | 上传门磁状态 | 仓控→柜控 | 6路仓体门开/关状态 |
+| 0x06 | 上传OTA状态 | 仓控→柜控 | OTA升级进度/结果 |
+| 0x81 | 继电器控制 | 柜控→仓控 | 充电继电器通断控制 |
+| 0x82 | 电源参数设置 | 柜控→仓控 | 电压/电流/开关/无协议充电参数 |
+| 0x83 | 指示灯控制 | 柜控→仓控 | 绿灯/红灯/闪烁 |
+| 0x84 | 查询指令 | 柜控→仓控 | 查询全量状态 |
+| 0x85 | 平台禁用 | 柜控→仓控 | 禁用/解除禁用 |
+| 0x86 | 模式切换 | 平台→柜控 | 切换平台模式/柜控模式 |
+| 0x87 | 接触器控制 | 平台→柜控 | 合闸/分闸指令 |
+| 0x88 | 门锁控制 | 柜控→仓控 | 开门/锁门,指定仓体(位掩码) |
+| 0x89 | OTA升级 | 柜控→仓控 | 固件升级数据包/启动升级/查询版本 |
+| 0x8A | BMS类型配置 | 柜控→仓控 | 设置当前BMS协议类型(星恒/天能/无协议) |
+| 0x8B | 报警阈值配置 | 柜控→仓控 | 设置关门超时/高温报警阈值 |
+
+## 4. 定制主板设计
+
+### 4.1 仓控主板(ID1-6)
+
+#### 4.1.1 硬件规格
+
+| 模块 | 规格 |
+|------|------|
+| MCU | 内置MCU控制器,独立运行,支持OTA固件升级 |
+| 继电器 | 6路直流继电器,寿命≥10万次 |
+| 电子门锁驱动 | 6路,MOSFET输出/继电器,12V电控锁驱动 |
+| 门磁检测 | 6路开关量输入,检测仓门开/关状态 |
+| 485接口 | 3路隔离485:1路接柜控板(ID128)、1路接BMS(6仓体共用总线)、1路接所属电源模块 |
+| 光耦隔离 | 3路485均做光耦隔离 |
+| 温控接口 | 6路,适配常用温控传感器(NTC/DS18B20) |
+| ID设置 | 4位拨码开关,设置与柜控板通讯的485从站ID,范围1-6 |
+| 指示灯接口 | 6路(每仓体1路),支持绿灯/红灯控制 |
+
+#### 4.1.2 接口定义
+
+```
+┌─────────────────────────────────────┐
+│ 仓控主板 (ID1-6) │
+├─────────────────────────────────────┤
+│ [拨码开关] ← 设置485从站ID (1-6) │
+│ [485_1] ← 柜控主板 (ID128) │
+│ [485_2] ← BMS总线 (6仓体共用) │
+│ [485_3] ← 所属电源 (1TN协议) │
+│ [TEMP1-6] ← 6路仓体温控 │
+│ [RELAY1-6] ← 6路直流继电器 │
+│ [DOOR_LK1-6]→ 6路电子门锁驱动 │
+│ [DOOR_ST1-6]← 6路门磁检测 │
+│ [LED1-6] ← 6路仓体指示灯 │
+│ [4PIN] ← 充电线(2路正负极+2路485)│
+└─────────────────────────────────────┘
+```
+
+#### 4.1.3 固件功能模块
+
+| 模块 | 功能 |
+|------|------|
+| 485从站模块 | 响应柜控板(ID128)指令,按拨码开关ID寻址 |
+| BMS通讯模块 | 按配置协议(星恒/天能/无协议)读取BMS数据或跳过 |
+| 电源通讯模块 | 通过独立485与所属电源1TN协议交互 |
+| 继电器控制模块 | 6路独立通断控制 |
+| 指示灯控制模块 | 6路独立LED状态控制,关联门锁/充电/告警状态 |
+| 温控采集模块 | 6路温度数据采集 |
+| ID管理模块 | 拨码开关读取与ID识别 |
+| 门锁控制模块 | 6路独立门锁驱动,接收柜控板门锁指令开锁 |
+| 门磁监测模块 | 6路门磁状态检测,上传门状态,超时未关门报警 |
+| OTA升级模块 | 接收固件升级数据包,校验写入,重启切换 |
+
+#### 4.1.4 核心逻辑流程
+
+```
+初始化:
+ 1. 读取拨码开关 → 获取本机ID (1-6)
+ 2. 初始化3路485接口(9600, 8, 1, none)
+ 3. 初始化继电器 → 全部断开
+ 4. 初始化门锁 → 全部锁闭
+ 5. 初始化指示灯 → 全部绿灯常亮(空闲)
+ 6. 初始化温控接口 → 开始采集
+ 7. 从柜控板获取BMS协议类型配置
+ 8. 从柜控板获取报警阈值配置(关门超时/高温)
+ 9. 注册到柜控板 → 发送上线通知(源ID=本机ID, 目标ID=128)
+
+主循环:
+ 每个周期(100ms):
+ 1. 检查柜控板指令(485_1)
+ → 如有指令:解析功能码,执行对应操作
+ → 门锁控制(0x88):按位掩码开门/锁门
+ → OTA升级(0x89):处理固件升级数据
+ → BMS配置(0x8A):切换BMS协议类型
+ → 报警阈值配置(0x8B):更新关门超时/高温阈值
+ 2. 按配置的BMS协议类型轮询BMS总线(485_2)
+ → 星恒模式:Modbus-RTU读取电池数据
+ → 天能模式:自定义协议读取电池数据
+ → 无协议模式:跳过BMS读取
+ → 解析数据,故障时联动继电器+指示灯
+ 3. 读取6路温控数据
+ → 超范围时触发保护
+ → 温度超过高温报警阈值 → 上传告警
+ 4. 读取6路门磁状态
+ → 检测到开门 → 启动计时
+ → 超过设置阈值未关门 → 上传告警
+ 5. 通过485_3查询所属电源状态
+ → 读取电压/电流/故障码(1TN协议)
+ → 无协议充电模式:监测输出电流,低于阈值通知柜控板停止
+ 6. 上传数据至柜控板(ID128)
+ → BMS数据 + 温控 + 电源状态 + 继电器状态 + 门磁状态
+ 7. 更新指示灯状态
+
+充电启动流程:
+ 1. 检测到仓门关闭(门磁闭合)
+ 2. 按配置协议读取BMS数据(无协议模式跳过)
+ 3. 上传数据至柜控板(ID128),等待充电指令
+ 4. 收到允许充电指令 → 闭合继电器
+ → 通过485_3发送电源开启+调压调流指令(1TN协议)
+ → 无协议模式:使用平台下发的固定电压/电流值
+ → 指示灯切换为绿灯闪烁(充电中)
+ 5. 实时同步数据,动态调整电源参数
+ → 有BMS模式:根据BMS请求调整
+ → 无协议模式:监测电流,低于阈值自动停止
+ 6. 充满/异常 → 断开继电器,关闭电源输出
+
+门开锁流程:
+ 1. 用户扫码 → 平台验证通过
+ 2. 平台下发开门指令至柜控板(TCP)
+ 3. 柜控板转发0x88开门指令至对应仓控板
+ 4. 仓控板解析位掩码 → 驱动对应门锁继电器/MOSFET → 开锁
+ 5. 门锁动作时间到 → 自动复位
+ 6. 用户开门取/放电池
+ 7. 门磁检测到关门 → 上报门已关闭状态
+```
+
+### 4.2 柜控主板(ID128)
+
+#### 4.2.1 硬件规格
+
+| 模块 | 规格 |
+|------|------|
+| MCU | 内置MCU控制器,独立运行,支持OTA固件升级 |
+| 485接口 | 1路隔离RS485,与6个仓控板通讯(光耦隔离) |
+| 485电表接口 | 1路隔离RS485,接DL/T645电表,读取耗电量数据 |
+| 4G模块 | Air780E,自定义TCP协议长连接 |
+| 烟感接口 | 1路开关量输入 |
+| 浸水接口 | 1路开关量输入 |
+| 倾倒检测 | 1路开关量输入 |
+| 环境温控传感器 | 1路,NTC/DS18B20,用于加热风扇自动温控 |
+| 接触器控制 | 1路继电器输出,控制常开接触器(默认断开,给信号吸合) |
+| 喷淋电磁阀 | 1路继电器输出,烟感触发时自动打开,喷淋灭火 |
+| 加热风扇继电器 | 1路独立继电器输出,220V/10A,自动温控 |
+| 电磁泵继电器 | 1路继电器输出,平台远程控制通断 |
+| 接触器按钮 | 2个物理按钮:合闸按钮(吸合接触器)、分闸按钮(断开接触器),用于离线时手动操作 |
+| 存储 | Flash(存储设备配置参数:BMS类型、报警阈值、无协议充电参数,本地不留充电数据) |
+| 主电源 | 来自12V电源(预接触器,常供电) |
+| 后备电源 | 后备电池输入,380V总断电时自动切换,维持板子供电与远程通讯 |
+| | 平台可远程下发恢复指令 → 吸合接触器恢复充电电源供电 |
+
+#### 4.2.2 接口定义
+
+```
+┌─────────────────────────────────────┐
+│ 柜控主板 (ID128) │
+├─────────────────────────────────────┤
+│ [485] ←→ 仓控板ID1-6 │
+│ [485_METER]←→ DL/T645电表(耗电量采集)│
+│ [4G/TCP] ←→ 后台平台(自定义TCP长连接)│
+│ [SMOKE] ← 烟感传感器(开关量) │
+│ [WATER] ← 浸水传感器(开关量) │
+│ [TILT] ← 倾倒检测(开关量) │
+│ [TEMP_AMB] ← 环境温度传感器(NTC/DS18B20)│
+│ [CONTACTOR]→ 接触器控制(继电器输出) │
+│ [SPRINKLER]→ 喷淋电磁阀控制(继电器输出)│
+│ [FAN] → 加热风扇控制(独立继电器输出)│
+│ [PUMP] → 电磁泵控制(继电器输出) │
+│ [合闸按钮] ← 手动吸合接触器 │
+│ [分闸按钮] ← 手动断开接触器 │
+│ [PWR_IN] ← 12V电源输入 │
+│ [PWR_OUT] → 仓控板供电输出 │
+└─────────────────────────────────────┘
+```
+
+#### 4.2.3 固件功能模块
+
+| 模块 | 功能 |
+|------|------|
+| 485主站模块 | 与仓控板ID1-6双向通讯,ID寻址 |
+| TCP通讯模块 | 与后台平台数据交换(自定义TCP协议长连接) |
+| 数据处理模块 | BMS/温控/电源/门磁数据解析转发 |
+| 充电控制模块 | 双模式充电控制逻辑(平台/本地),支持无协议充电参数管理 |
+| 安全监测模块 | 烟感/浸水/倾倒监测,触发后断开接触器切断380V + 打开喷淋电磁阀 |
+| 接触器控制模块 | 控制380V接触器吸合(上电/恢复)/断开(告警/急停),含按钮去抖逻辑 |
+| 按钮处理模块 | 合闸→吸合接触器,分闸→断开接触器,离线时手动操控,优先级高于平台指令 |
+| 告警联动模块 | 烟感/浸水→断开接触器+打开喷淋→上报平台→记录日志 |
+| 加热风扇控制模块 | 自动温控:读取环境温度传感器,<0°C启动加热,≥25°C停止 |
+| 电磁泵控制模块 | 接收平台远程指令控制继电器通断 |
+| 门锁指令转发模块 | 接收平台开门指令 → 转发对应仓控板(0x88) |
+| OTA升级模块 | 接收平台固件包 → 分片转发至仓控板或本地升级 |
+| BMS配置管理模块 | 存储/切换BMS协议类型,新配置下发至仓控板 |
+| 电表采集模块 | 通过独立485按DL/T645协议读取电表耗电量,定时上报平台 |
+
+#### 4.2.4 核心逻辑流程
+
+```
+初始化:
+ 1. 初始化485主站(9600, 8, 1, none)
+ 2. 初始化4G模块 → 与后台平台建立TCP长连接
+ 3. 初始化烟感/浸水接口(中断模式)
+ 4. 初始化接触器继电器 → 吸合(系统正常上电)
+ 5. 读取存储的配置参数(BMS类型、报警阈值、无协议充电参数)
+ 6. 轮询所有仓控板(ID1-6)上线状态
+ 7. 下发BMS类型配置至所有仓控板
+ 8. 下发报警阈值配置至所有仓控板
+ 9. 上传设备上线通知至平台
+
+主循环:
+ 每500ms:
+ 1. 轮询仓控板ID1-6数据
+ → 发送查询指令 → 接收BMS/温控/电源/门磁/设备状态
+ → 按ID分类存储
+ 2. 处理后台下行指令(TCP长连接接收)
+ → 仓体禁用/解除 → 转发对应仓控板
+ → 充电控制指令 → 转发对应仓控板
+ → 电磁泵控制 → 通/断控制电磁泵继电器
+ → 门锁控制 → 开门指令转发对应仓控板(0x88)
+ → OTA升级 → 固件包处理,转发仓控板或本地升级
+ → BMS类型配置 → 存储+下发仓控板(0x8A)
+ → 报警阈值配置 → 存储+下发仓控板(0x8B)
+ → 无协议充电参数 → 存储+转发仓控板
+ → 配置更新 → 本地存储+生效
+ 3. 安全监测检查(最高优先级)
+→ 烟感触发 → 立即断开接触器 + 打开喷淋电磁阀
+ → 浸水触发 → 立即断开接触器
+ → 倾倒触发 → 立即断开接触器 + 上传告警
+ → 发送急停指令至所有仓控板(断开继电器)
+ → 板子仍由12V电源正常供电,通讯不受影响
+ → 上传告警至平台
+ → 等待人工/平台远程复位才可重新吸合接触器
+ 4. 电表数据采集
+ → 通过485_METER按DL/T645协议读取电表耗电量
+ → 定时采集(每5分钟),缓存累计数据
+ → 上报平台
+ 5. 加热风扇自动温控
+ → 读取环境温度传感器
+ → 温度 < 0°C → 闭合加热风扇继电器(启动加热)
+ → 温度 ≥ 25°C → 断开加热风扇继电器(停止加热)
+ → 温度在0-25°C之间 → 保持当前状态(滞回控制)
+ → 状态变化时上传平台
+ 5. 接触器状态同步
+ → 接触器异常断开 → 上报平台
+ → 平台远程恢复 → 吸合接触器,恢复充电电源供电
+ 6. 充电控制逻辑
+ → 平台模式(默认):等待平台远程指令,需在线
+ → 柜控模式:由平台远程切换为本地模式,离线也可正常充电
+ · 平台下发"启用本地模式"指令 → 柜控板切换为本地控制
+ · 本地校验项:BMS握手成功/无协议充电参数合规、电池参数合规、仓体未被禁用
+ · 符合条件 → 下发允许充电指令
+ · 不符合条件 → 下发拒绝指令(红灯闪烁)
+ → 平台模式/柜控模式可通过平台远程切换
+ 8. 数据上传
+ → 实时上报BMS/温控/状态数据供平台监控
+ → 上报加热风扇状态(运行/停止)及环境温度
+ → 上报电磁泵状态(通/断)
+ → 上报各仓体门磁状态(开/关)及关门超时告警
+ → 上报烟感/喷淋状态
+ → 上报电表耗电量数据
+ → 平台仅记录充电记录(起止时间、电量、费用等)
+
+TCP断连处理:
+ 1. 检测到TCP断连 → 触发告警
+ 2. 如已启用柜控模式,离线充电不受影响
+ 3. 如为平台模式 → 充电控制暂停,等待重连
+ 4. 缓存待上传数据(重连后补偿上报)
+ 5. 自动重连(间隔递增:10s/30s/60s/300s)
+ 6. 重连成功 → 补偿上传缓存数据
+```
+
+## 5. 指示灯状态控制
+
+### 5.1 状态定义
+
+| 指示灯状态 | 对应状态 | 触发条件 | 继电器 | 电源输出 |
+|-----------|---------|---------|--------|---------|
+| 绿灯闪烁 | 充电中 | 正常充电,BMS通讯正常(有协议)或电源输出正常(无协议) | 闭合 | 正常输出 |
+| 绿灯常亮 | 仓体空闲 | 无充电任务、无故障、无禁用、门已关闭 | 断开 | 无输出 |
+| 红灯常亮 | 平台禁用 | 管理员下发禁用指令 | 断开 | 无输出 |
+| 红灯闪烁 | 电池不符合要求/设备告警/门未关 | BMS校验不通过/故障/门长期未关 | 断开 | 无输出 |
+
+### 5.2 状态切换条件
+
+```
+[绿灯常亮] ──扫码/预约充电──→ [绿灯闪烁]
+[绿灯闪烁] ──充满/用户停止──→ [绿灯常亮]
+[绿灯常亮] ──平台禁用指令──→ [红灯常亮]
+[红灯常亮] ──平台解除禁用──→ [绿灯常亮]
+[绿灯常亮] ──插入不合规电池──→ [红灯闪烁]
+[红灯闪烁] ──拔掉电池──→ [绿灯常亮]
+[绿灯闪烁] ──故障告警──→ [红灯闪烁]
+[红灯闪烁] ──故障排除──→ [绿灯常亮]
+任何状态 ──烟感/浸水触发──→ [红灯闪烁/常亮]
+```
+
+### 5.3 控制实现
+
+指示灯控制由仓控主板直接驱动,柜控主板通过485下发指示灯控制指令(功能码0x83),仓控板执行具体GPIO控制:
+
+```
+绿灯控制:GPIO输出PWM(闪烁)/高电平(常亮)/低电平(灭)
+红灯控制:GPIO输出PWM(闪烁)/高电平(常亮)/低电平(灭)
+```
+
+## 6. 充电控制逻辑
+
+### 6.1 电池握手流程
+
+```
+[用户扫码 → 平台验证 → 下发开门指令]
+ ↓
+[仓门打开 → 用户放入电池 → 关闭仓门]
+ ↓
+仓控板检测门磁闭合
+ ↓
+按配置BMS协议类型读取电池数据:
+ ┌─ 星恒模式:Modbus 03H读取寄存器
+ │ - 10000-10002: 版本+控制状态
+ │ - 10003-10027: 电池条码
+ │ - 10043-10072: 单体电压(30串)
+ │ - 10074: 总电压
+ │ - 10080-10089: 温度(10路)
+ │ - 10091-10099: 告警状态
+ │ - 10101: SOH
+ │ - 10102: SOC
+ │ - 10103-10104: 充电请求参数
+ ├─ 天能模式:自定义协议读取对应数据
+ └─ 无协议模式:跳过BMS读取,使用平台下发的固定参数
+ ↓
+数据有效性校验(SOC 0-100%, 电压40-80V等)
+ ↓
+打包数据 → 上传至柜控板(ID128)→ TCP长连接上传平台
+ ↓
+等待充电控制指令
+```
+
+### 6.2 双模式充电控制
+
+#### 6.2.1 平台控制模式
+
+```
+后台平台接收BMS信息 → 管理员判断/自动规则
+ ↓
+下发充电允许/禁止指令
+ ↓
+柜控板接收 → 转发至对应仓控板
+ ↓
+仓控板执行:
+ 允许 → 闭合继电器 → 电源开启+调压调流 → 绿灯闪烁
+ 禁止 → 断开继电器 → 电源关闭 → 红灯闪烁
+```
+
+#### 6.2.2 柜控板控制模式
+
+```
+后台预设触发条件(特殊ID列表、参数规则等)
+ ↓
+柜控板接收BMS信息 → 自动校验条件
+ ↓
+符合条件 → 下发充电允许 → 仓控板执行充电启动
+不符合条件 → 下发禁止 → 红灯闪烁
+ ↓
+校验结果上传至平台
+ ↓
+拔掉电池 → 自动恢复绿灯常亮(空闲)
+```
+
+### 6.3 充电过程动态调整
+
+```
+每1秒循环:
+ 1. 读取BMS实时数据(有协议模式)/ 读取电源输出电流(无协议模式)
+ 2. SOC ≥ 98% → 满电保护:关闭电源,绿灯常亮
+ 3. 电池温度 > 55℃ 或 < -10℃ → 过温保护:关闭电源
+ 4. 仓体温度 > 高温报警阈值(平台可配置,默认55℃)→ 高温告警上传
+ 5. 告警状态检查:
+ - 单体过压/欠压 → 保护动作
+ - 过流 → 降低电流或断电
+ - 电芯不平衡 → 预警提示
+ 6. 动态调压调流:
+ - 有协议模式:根据BMS请求电压/电流调整电源输出(功能码0x02/0x04)
+ - 无协议模式:保持平台下发的固定电压/电流输出
+ - 电压偏差 ≥ 0.5V 时重新调节
+ 7. 无协议模式低电流检测:
+ - 输出电流 < 平台设置的停止阈值 → 判定充满,自动断电
+```
+
+### 6.4 无协议充电模式
+
+当配置为无协议充电模式时,充电过程不依赖BMS通讯,由平台下发固定参数控制:
+
+**配置参数(平台设置):**
+
+| 参数 | 说明 | 范围 |
+|------|------|------|
+| 充电电压 | 固定输出电压 | 40-80V |
+| 充电电流 | 固定输出电流 | 5-20A |
+| 停止电流阈值 | 低于此值判定充满自动停止 | 0.5-5A |
+
+**充电流程:**
+
+```
+[用户放入电池 → 关闭仓门]
+ ↓
+仓控板检测门磁闭合
+ ↓
+无BMS握手 → 直接上传"仓体就绪"至平台
+ ↓
+平台下发充电允许 + 充电参数(电压/电流/停止电流阈值)
+ ↓
+仓控板闭合继电器 → 通过485_3设置电源输出固定电压/电流
+ ↓
+指示灯切换为绿灯闪烁(充电中)
+ ↓
+每1秒监测电源输出电流
+ ↓
+输出电流 < 停止电流阈值 → 判定充满
+ ↓
+断开继电器 → 关闭电源输出 → 绿灯常亮
+ ↓
+上报充电完成(时长+总输出电量估算)
+```
+
+## 7. 软件平台设计
+
+### 7.1 后台管理系统
+
+#### 7.1.1 技术栈建议
+
+```
+后端框架:Spring Boot / Node.js (Egg.js/Nest.js) / Python (FastAPI)
+数据库:MySQL + Redis + InfluxDB(时序数据)
+通信协议:自定义TCP协议长连接(设备通讯)+ HTTP/WebSocket(前端)
+前端框架:React / Vue3 + Element Plus / Ant Design
+```
+
+#### 7.1.2 核心模块
+
+| 模块 | 功能 |
+|------|------|
+| 设备管理 | 实时监控、远程重启、参数配置、OTA固件升级(柜控板+仓控板) |
+| 仓体控制 | 独立电流/电压设置、禁用/解除、状态查看、远程开门 |
+| BMS协议配置 | 设置当前柜体BMS协议类型(星恒/天能/无协议充电) |
+| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
+| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
+| 用户管理 | 注册/登录、权限分配(管理员/普通/骑手) |
+| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计 |
+| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
+| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置、设备告警管理 |
+| TCP连接管理 | 连接状态监控、心跳检测、断连告警 |
+| 辅助设备控制 | 电磁泵远程通断控制、加热风扇状态监控 |
+
+#### 7.1.3 设备数据模型
+
+```
+充电柜 (Cabinet):
+ - id, 名称, 位置, 状态(在线/离线)
+ - BMS协议类型(星恒/天能/无协议)
+ - 无协议充电参数(电压/电流/停止电流阈值)
+ - 报警阈值(关门超时/高温)
+ - 6个电源 (Power): 型号, 版本, 状态
+ - 36个仓体 (Compartment):
+ - 所属电源, 所属仓控板ID
+ - 状态(空闲/充电中/禁用/故障/门未关)
+ - 当前电压/电流设置
+ - 指示灯状态
+ - 门磁状态(开/关) · 门锁状态
+ - 关门超时告警
+ - 6个仓控板 (CabinBoard):
+ - ID(1-6), 状态, 固件版本, OTA升级进度
+ - 6路温控数据
+ - 6路门磁数据
+ - 1个柜控板 (CabinetBoard):
+ - ID(128), 状态, 固件版本, OTA升级进度
+ - TCP连接状态 · 4G信号强度
+ - 加热风扇状态(运行/停止) · 环境温度
+ - 电磁泵状态(通/断)
+ - 喷淋电磁阀状态(开/关)
+ - 电表耗电量(kWh) · 电表通讯状态
+ - 烟感/浸水/喷淋状态
+
+充电记录 (ChargeRecord):
+ - 仓体ID, 仓控板ID, 电源通道
+ - 开始时间, 结束时间
+ - 充电量, 最高温度, 平均电压
+ - BMS数据快照(SOC/电压/温度)
+ - 充电状态(正常/异常中断/手动停止)
+
+故障记录 (FaultRecord):
+ - 仓体ID/主板ID, 故障类型, 故障码
+ - 发生时间, 处理时间
+ - 处理状态(待处理/处理中/已解决)
+ - 故障等级(预警/保护/严重)
+```
+
+### 7.2 微信小程序
+
+#### 7.2.1 功能页面
+
+| 页面 | 功能 |
+|------|------|
+| 首页 | 附近充电柜列表/地图,空闲舱位显示 |
+| 扫码充电 | 扫描仓体二维码,启动充电 |
+| 预约充电 | 选择仓体,预约时间段 |
+| 充电进度 | 实时显示SOC/电压/温度/充电时间 |
+| 消息通知 | 充电完成、故障告警、系统通知 |
+| 个人中心 | 充电记录、用户信息、反馈 |
+
+#### 7.2.2 仓体状态展示
+
+小程序同步显示仓体指示灯状态及门锁状态:
+- 绿灯闪烁 → "充电中"
+- 绿灯常亮 + 门锁图标开 → "空闲(门已开)"
+- 绿灯常亮 + 门锁图标关 → "空闲(门已关)"
+- 红灯 → "禁用/故障"
+
+扫码开门流程:
+1. 用户扫描仓体二维码
+2. 小程序显示仓体信息和操作按钮
+3. 点击"开门"按钮 → 平台验证 → 下发开门指令
+4. 门锁打开,用户放入/取出电池
+5. 关门后系统自动检测门磁闭合
+
+## 8. 安全防护设计
+
+### 8.1 电气安全
+
+| 防护类型 | 实现方式 | 触发动作 |
+|---------|---------|---------|
+| 过充保护 | SOC监测 + 电压监测 | SOC≥98%自动断电 |
+| 过温保护 | 温控传感器 + BMS温度 | 超过平台设置高温报警阈值(可配置)时告警+断电 |
+| 过流保护 | 电源故障码监测 | 超过设定值断电 |
+| 短路保护 | 电源故障码监测 | 即时断电 |
+| 漏电保护 | 漏保 | 快速切断380V输入 |
+| 浪涌保护 | 浪涌保护器件 | 吸收浪涌冲击 |
+| 烟感保护 | 开关量烟感传感器 | 断开接触器切断380V + 打开喷淋电磁阀 + 告警 |
+| 浸水保护 | 开关量浸水传感器 | 断开接触器切断380V + 告警 |
+| 门未关报警 | 门磁传感器+计时 | 超过平台设置关门超时阈值(可配置)时上传告警 |
+
+### 8.2 数据安全
+
+| 安全类型 | 措施 |
+|---------|------|
+| 存储加密 | 用户信息、BMS数据加密存储 |
+| 通讯校验 | 1TN和校验 + Modbus CRC16校验 |
+| 网络传输 | 自定义TCP协议加密传输 |
+| 权限管控 | 管理员/普通用户分级权限 |
+
+### 8.3 防护联动流程
+
+```
+[烟感/浸水触发]
+ ↓
+柜控板检测到开关量变化(中断)
+ ↓
+烟感触发 → 立即断开接触器(切断充电电源380V输入)+ 打开喷淋电磁阀(简易喷淋)
+浸水触发 → 立即断开接触器(切断充电电源380V输入)
+ ↓
+发送0x81急停指令至所有仓控板(ID1-6):继电器全部断开
+ ↓
+柜控板+仓控板仍由12V电源正常供电(预接触器),4G通讯在线
+ ↓
+所有仓体指示灯切换为红灯(告警)
+ ↓
+上传告警至后台平台(即时推送)
+ ↓
+管理员接收通知 → 判断是否为误报警
+ ↓
+误报警 → 平台下发恢复指令 → 关闭喷淋电磁阀 → 柜控板吸合接触器 → 充电电源恢复供电
+ ↓
+真实告警 → 现场排查处理 → 灭火后平台关闭喷淋 → 故障排除后恢复
+ ↓
+各仓体恢复空闲(绿灯常亮)
+```
+
+## 9. 性能指标
+
+| 指标 | 要求 |
+|------|------|
+| 仓控-柜控通讯延迟 | ≤ 0.5s |
+| 仓控-电源通讯延迟 | ≤ 0.3s |
+| TCP数据上传延迟 | ≤ 1s |
+| 指示灯状态切换延迟 | ≤ 0.5s |
+| 平台指令执行延迟 | ≤ 1s |
+| 门锁响应延迟(平台→开门) | ≤ 1.5s |
+| 门磁检测上报延迟 | ≤ 1s |
+| OTA升级成功率 | ≥ 99% |
+| 烟感→喷淋联动延迟 | ≤ 0.5s |
+| 通讯故障率 | ≤ 0.1% |
+| MTBF | ≥ 8000小时 |
+| 设备故障率 | ≤ 0.5% |
+| 继电器寿命 | ≥ 10万次 |
+| 指示灯寿命 | ≥ 50000小时 |
+| 工作温度 | -10℃ ~ +70℃ |
+| 工作湿度 | 10% ~ 90% |
diff --git a/docs/开发计划.md b/docs/开发计划.md
new file mode 100644
index 0000000..064c823
--- /dev/null
+++ b/docs/开发计划.md
@@ -0,0 +1,347 @@
+# 36仓体直流电瓶车充电柜 - 开发计划
+
+## 1. 项目概述
+
+- **项目名称**:36仓体直流电瓶车充电柜
+- **开发周期**:12周
+- **核心交付**:充电柜硬件(含仓控主板×6、柜控主板×1)+ 后台管理系统 + 微信小程序
+
+---
+
+## 2. 阶段划分
+
+### 第1阶段:需求确认与方案设计(第1-2周)
+
+**目标**:完成需求确认、技术调研、方案定稿
+
+| 任务 | 负责人 | 输出物 | 时间 |
+|------|--------|--------|------|
+| 需求评审与确认 | PM/研发 | 需求规格说明书 | 第1周 |
+| 技术调研 | 研发 | 技术调研报告 | 第1周 |
+| 系统架构设计 | 架构师 | 系统架构设计文档 | 第2周 |
+| 硬件方案设计 | 硬件工程师 | 硬件方案文档 | 第2周 |
+| 元器件选型 | 硬件工程师 | 选型清单(BOM) | 第2周 |
+| 仓控主板详细设计 | 硬件工程师 | 仓控主板原理图+PCB设计 | 第2周 |
+| 柜控主板详细设计 | 硬件工程师 | 柜控主板原理图+PCB设计 | 第2周 |
+| 通讯协议设计 | 嵌入式工程师 | 协议设计文档 | 第2周 |
+| 指示灯选型确认 | 硬件工程师 | 指示灯规格确认单 | 第2周 |
+| 平台禁用功能方案 | PM/研发 | 功能方案文档 | 第2周 |
+| 电池握手方案设计 | 嵌入式工程师 | 握手流程设计文档 | 第2周 |
+| 双模式充电控制方案 | 嵌入式工程师 | 控制逻辑设计文档 | 第2周 |
+| 接口定义评审 | 全组 | 接口定义确认 | 第2周 |
+| 仓控板拨码ID方案 | 硬件工程师 | ID设置方案确认 | 第2周 |
+| 仓控-电源独立通讯链路设计 | 嵌入式工程师 | 通讯链路设计文档 | 第2周 |
+
+> **里程碑1(第2周末)**:需求确认完成,技术方案定稿,元器件选型完成,仓控板与柜控板定制方案明确。
+
+---
+
+### 第2阶段:硬件开发与软件开发(第3-6周)
+
+#### 2.1 硬件开发
+
+| 任务 | 负责人 | 输出物 | 时间 |
+|------|--------|--------|------|
+| 充电柜本体结构设计 | 结构工程师 | 3D模型+工程图 | 第3-4周 |
+| 充电柜本体打样组装 | 工厂 | 样机柜体 | 第4-5周 |
+| 电源选型采购 | 采购 | 电源到货 | 第3周 |
+| 指示灯选型采购 | 采购 | 指示灯到货 | 第3周 |
+| 通讯模块采购 | 采购 | 4G模块到货 | 第3周 |
+| 仓控主板PCB打样 | PCB工厂 | 仓控板PCB | 第4周 |
+| 柜控主板PCB打样 | PCB工厂 | 柜控板PCB | 第4周 |
+| 仓控主板焊接调试 | 硬件工程师 | 仓控板6块(功能正常) | 第5周 |
+| 柜控主板焊接调试 | 硬件工程师 | 柜控板1块(功能正常) | 第5周 |
+| 继电器通断测试 | 硬件工程师 | 测试报告 | 第5周 |
+| 光耦隔离效果测试 | 硬件工程师 | 测试报告 | 第5周 |
+| 485通讯调试 | 硬件工程师 | 通讯功能正常 | 第5周 |
+| 温控接口调试 | 硬件工程师 | 6路温控采集正常 | 第5周 |
+| 仓控板拨码开关ID测试 | 硬件工程师 | ID识别准确 | 第5-6周 |
+| 仓控板门锁/门磁调试 | 硬件工程师 | 6路门锁驱动+门磁检测正常 | 第6周 |
+| 仓控-电源独立通讯调试 | 嵌入式工程师 | 通讯链路正常 | 第6周 |
+| 4G模块调试 | 嵌入式工程师 | 4G通讯正常 | 第6周 |
+| 烟感/浸水接口调试 | 硬件工程师 | 开关量检测正常 | 第6周 |
+| 喷淋电磁阀调试 | 硬件工程师 | 烟感联动开启正常 | 第6周 |
+| 环境温控传感器调试 | 硬件工程师 | 温度采集正常 | 第6周 |
+| 加热风扇继电器调试 | 硬件工程师 | 独立通断正常 | 第6周 |
+| 电磁泵继电器调试 | 硬件工程师 | 通断控制正常 | 第6周 |
+| 指示灯接线与调试 | 硬件工程师 | 灯光状态正常 | 第6周 |
+| 整机装配 | 工厂 | 样机完成 | 第6周 |
+
+#### 2.2 软件开发
+
+| 任务 | 负责人 | 输出物 | 时间 |
+|------|--------|--------|------|
+| 仓控板固件-485从站通讯 | 嵌入式工程师 | 代码+单元测试 | 第3-4周 |
+| 仓控板固件-BMS通讯模块(Modbus-RTU) | 嵌入式工程师 | 代码+单元测试 | 第3-5周 |
+| 仓控板固件-BMS通讯模块(天能协议) | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
+| 仓控板固件-无协议充电模块 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 仓控板固件-电源通讯模块(1TN协议) | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
+| 仓控板固件-继电器控制 | 嵌入式工程师 | 代码+单元测试 | 第4周 |
+| 仓控板固件-门锁控制 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
+| 仓控板固件-门磁监测 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
+| 仓控板固件-指示灯控制 | 嵌入式工程师 | 代码+单元测试 | 第4周 |
+| 仓控板固件-温控采集 | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
+| 仓控板固件-ID管理(拨码开关) | 嵌入式工程师 | 代码+单元测试 | 第4周 |
+| 仓控板固件-数据上传/指令接收 | 嵌入式工程师 | 代码+单元测试 | 第5周 |
+| 仓控板固件-OTA升级 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 仓控板固件-BMS协议适配层 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-485主站通讯(ID寻址) | 嵌入式工程师 | 代码+单元测试 | 第4-5周 |
+| 柜控板固件-4G通讯模块 | 嵌入式工程师 | 代码+单元测试 | 第4-6周 |
+| 柜控板固件-数据处理与存储 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-充电控制逻辑(双模式) | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-烟感/浸水监测 | 嵌入式工程师 | 代码+单元测试 | 第5周 |
+| 柜控板固件-安全监测与联动 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-加热风扇温控 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-电表采集(DL/T645) | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-电磁泵控制 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-喷淋电磁阀控制 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-门锁指令转发 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-OTA升级管理 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 柜控板固件-BMS配置管理 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+| 后台系统-设备管理模块 | 后端工程师 | 接口+管理界面 | 第3-5周 |
+| 后台系统-仓体控制模块 | 后端工程师 | 接口+管理界面 | 第4-6周 |
+| 后台系统-用户管理模块 | 后端工程师 | 接口+管理界面 | 第3-4周 |
+| 后台系统-数据统计模块 | 后端工程师 | 接口+报表页面 | 第5-6周 |
+| 后台系统-故障管理模块 | 后端工程师 | 接口+管理界面 | 第5-6周 |
+| 后台系统-BMS协议配置 | 后端工程师 | 接口+配置界面 | 第5-6周 |
+| 后台系统-无协议充电参数配置 | 后端工程师 | 接口+配置界面 | 第5-6周 |
+| 后台系统-报警阈值管理 | 后端工程师 | 接口+配置界面 | 第5-6周 |
+| 后台系统-OTA升级管理 | 后端工程师 | 接口+升级管理界面 | 第5-6周 |
+| 后台系统-仓体禁用功能 | 后端工程师 | 接口+管理界面 | 第5-6周 |
+| 后台系统-充电控制模式配置 | 后端工程师 | 接口+配置界面 | 第5-6周 |
+| 后台系统-4G通讯管理 | 后端工程师 | 接口+监控界面 | 第5-6周 |
+| 后台系统-4G MQTT服务端 | 后端工程师 | 服务+接口 | 第4-6周 |
+| 小程序-扫码充电 | 前端工程师 | 小程序页面+接口对接 | 第4-6周 |
+| 小程序-预约充电 | 前端工程师 | 小程序页面+接口对接 | 第4-6周 |
+| 小程序-充电进度查询 | 前端工程师 | 小程序页面+接口对接 | 第5-6周 |
+| 小程序-消息通知 | 前端工程师 | 推送功能 | 第5-6周 |
+| 小程序-故障反馈 | 前端工程师 | 小程序页面+接口对接 | 第5-6周 |
+| 电池握手验证程序 | 嵌入式/后端 | 代码+单元测试 | 第5-6周 |
+| 指示灯控制程序联调 | 嵌入式工程师 | 功能验证 | 第6周 |
+| 平台仓体禁用功能联调 | 全栈 | 功能验证 | 第6周 |
+| 柜控板-仓控板ID寻址通讯程序 | 嵌入式工程师 | 代码+单元测试 | 第5-6周 |
+
+> **里程碑2(第6周末)**:硬件开发调试完成,软件开发完成,各模块初步实现。
+
+---
+
+### 第3阶段:系统集成与联合调试(第7-9周)
+
+| 任务 | 负责人 | 时间 |
+|------|--------|------|
+| 仓控板-柜控板485通讯联调 | 嵌入式工程师 | 第7周 |
+| 仓控板-电源独立通讯联调 | 嵌入式工程师 | 第7周 |
+| 仓控板-BMS通讯联调 | 嵌入式工程师 | 第7周 |
+| 指示灯状态联动测试 | 全组 | 第7周 |
+| 平台仓体禁用功能联调 | 全栈 | 第7周 |
+| 电池握手验证联调 | 全组 | 第7-8周 |
+| 充电双模式控制联调 | 全组 | 第7-8周 |
+| 无协议充电联调 | 全组 | 第7-8周 |
+| 4G数据上传联调 | 全栈 | 第7-8周 |
+| 烟感/浸水+喷淋电磁阀联动联调 | 全组 | 第8周 |
+| 门锁控制联调(平台开门) | 全栈 | 第8周 |
+| 门磁监测+关门超时报警联调 | 全组 | 第8周 |
+| BMS多协议切换联调 | 全组 | 第8周 |
+| OTA固件升级联调 | 全组 | 第8周 |
+| 加热风扇温控联调 | 全组 | 第8周 |
+| 电磁泵远程控制联调 | 全组 | 第8周 |
+| 后台管理平台联调 | 全栈 | 第8周 |
+| 小程序联调 | 全栈 | 第8周 |
+| 仓控板ID寻址准确性测试 | 嵌入式工程师 | 第8周 |
+| 整机功能联调 | 全组 | 第8-9周 |
+| 异常场景排查与修复 | 全组 | 第8-9周 |
+| 性能调优 | 全组 | 第9周 |
+| 安全性测试 | 全组 | 第9周 |
+| 系统稳定性测试 | 全组 | 第9周 |
+
+> **里程碑3(第9周末)**:系统集成调试完成,所有功能正常,无重大故障。
+
+---
+
+### 第4阶段:试点测试(第10-11周)
+
+| 任务 | 负责人 | 时间 |
+|------|--------|------|
+| 36仓体同步运行测试 | 全组 | 第10周 |
+| 指示灯状态准确性全面测试 | 测试 | 第10周 |
+| 平台禁用功能可靠性测试 | 测试 | 第10周 |
+| 主板运行稳定性测试 | 测试 | 第10周 |
+| 电池握手与充电控制准确性测试 | 测试 | 第10-11周 |
+| 仓控板ID拨码设置准确性测试 | 测试 | 第10周 |
+| ID寻址通讯可靠性测试 | 测试 | 第10周 |
+| 仓控-电源独立通讯稳定性测试 | 测试 | 第10-11周 |
+| 4G通讯稳定性测试 | 测试 | 第10-11周 |
+| 烟感/浸水告警有效性测试 | 测试 | 第11周 |
+| 过充/过温/过流保护测试 | 测试 | 第11周 |
+| 实际使用场景模拟测试 | 全组 | 第11周 |
+| 问题收集与整改 | 全组 | 第11周 |
+
+> **里程碑4(第11周末)**:试点测试完成,所有问题整改完毕。
+
+---
+
+### 第5阶段:验收交付(第12周)
+
+| 任务 | 负责人 | 输出物 | 时间 |
+|------|--------|--------|------|
+| 开发文档整理 | 研发 | 开发文档定稿 | 第12周 |
+| 测试文档整理 | 测试 | 测试报告 | 第12周 |
+| 操作手册编写 | PM | 用户操作手册 | 第12周 |
+| 运维手册编写 | 运维 | 运维手册 | 第12周 |
+| 项目验收评审 | 全组 | 验收报告 | 第12周 |
+| 项目交接 | PM | 交接清单 | 第12周 |
+
+> **里程碑5(第12周末)**:项目验收通过,文档交付完成,正式交接。
+
+---
+
+## 3. 甘特图概览
+
+```
+任务\周次 | W1 | W2 | W3 | W4 | W5 | W6 | W7 | W8 | W9 | W10| W11| W12|
+--------------|----|----|----|----|----|----|----|----|----|----|----|----|
+需求确认 | ██ | ██ | | | | | | | | | | |
+方案设计 | ██ | ██ | | | | | | | | | | |
+结构设计 | | | ██ | ██ | | | | | | | | |
+PCB设计打样 | | | ██ | ██ | | | | | | | | |
+元器件采购 | | | ██ | | | | | | | | | |
+焊接调试 | | | | ██ | ██ | ██ | | | | | | |
+主机固件开发 | | | ██ | ██ | ██ | ██ | | | | | | |
+后台开发 | | | ██ | ██ | ██ | ██ | | | | | | |
+小程序开发 | | | | ██ | ██ | ██ | | | | | | |
+系统联调 | | | | | | | ██ | ██ | ██ | | | |
+性能优化 | | | | | | | | ██ | ██ | | | |
+试点测试 | | | | | | | | | | ██ | ██ | |
+问题整改 | | | | | | | | | | ██ | ██ | |
+文档编写 | | | | | | | | | | | ██ | ██ |
+验收交付 | | | | | | | | | | | | ██ |
+```
+
+---
+
+## 4. 关键里程碑
+
+| 里程碑 | 时间 | 交付物 |
+|--------|------|--------|
+| M1: 方案定稿 | 第2周末 | 需求规格说明书、技术方案文档、BOM清单、原理图 |
+| M2: 开发完成 | 第6周末 | 硬件样机、固件代码、后台系统、小程序 |
+| M3: 联调完成 | 第9周末 | 集成测试报告、功能确认书 |
+| M4: 试点完成 | 第11周末 | 试点测试报告、问题整改清单 |
+| M5: 验收交付 | 第12周末 | 开发文档、测试报告、操作手册、验收报告 |
+
+---
+
+## 5. 资源需求
+
+### 5.1 人员配置
+
+| 角色 | 人数 | 主要职责 |
+|------|------|---------|
+| 项目经理(PM) | 1 | 需求管理、进度把控、资源协调 |
+| 硬件工程师 | 2 | 仓控/柜控主板设计、调试、测试 |
+| 嵌入式工程师 | 2 | 仓控/柜控固件开发、协议对接 |
+| 后端工程师 | 2 | 后台系统开发、MQTT服务、数据库 |
+| 前端工程师 | 1 | 微信小程序开发 |
+| 测试工程师 | 1 | 单元/集成/系统测试 |
+| 结构工程师 | 1 | 柜体结构设计 |
+| 采购/生产 | 1 | 元器件采购、PCB打样、组装 |
+
+### 5.2 硬件清单(样机)
+
+| 物料 | 数量 | 说明 |
+|------|------|------|
+| 充电柜柜体 | 1台 | 36仓体结构 |
+| 充电电源 | 6台 | 直流充电电源,1TN协议 |
+| 仓控主板PCB | 10块 | 含备板 |
+| 柜控主板PCB | 3块 | 含备板 |
+| 4G模块 | 3个 | 含备板 |
+| 直流继电器 | 50个 | 含备件 |
+| 光耦隔离芯片 | 100个 | 485隔离用 |
+| 温控传感器 | 15个 | 含备件 |
+| 烟感传感器 | 2个 | 含备件 |
+| 浸水传感器 | 2个 | 含备件 |
+| 环境温控传感器 | 3个 | 含备件,用于加热风扇自动温控(NTC/DS18B20) |
+| 电子门锁(电控锁) | 40个 | 含备件,12V,每仓体1个 |
+| 门磁传感器 | 40个 | 含备件,每仓体1个 |
+| 喷淋电磁阀 | 2个 | 含备件,烟感联动喷淋 |
+| 加热风扇 | 1台 | 含独立继电器控制 |
+| 电磁泵 | 1台 | 含继电器驱动 |
+| 状态指示灯 | 50个 | 含备件 |
+| 拨码开关 | 10个 | 仓控板ID设置 |
+| 连接线束 | 1批 | 4PIN线等 |
+
+### 5.3 软件环境
+
+| 类型 | 环境/工具 |
+|------|-----------|
+| 嵌入式IDE | Keil MDK / STM32CubeIDE |
+| 后台开发 | Spring Boot / Node.js / Python + MySQL + Redis + InfluxDB |
+| 小程序开发 | 微信开发者工具 |
+| 协议测试 | Modbus Poll / 串口调试助手 |
+| 版本管理 | Git |
+| 项目管理 | 禅道 / Jira |
+
+---
+
+## 6. 风险与应对
+
+| 风险 | 概率 | 影响 | 应对措施 |
+|------|------|------|---------|
+| 星恒BMS协议版本不匹配 | 中 | 高 | 提前获取最新协议文档,预留协议适配层 |
+| 电源1TN协议兼容性问题 | 中 | 高 | 提前联调电源厂商,准备协议测试环境 |
+| 485通讯干扰(36舱全开) | 中 | 高 | 光耦隔离+终端电阻+合理布线 |
+| 4G信号不稳定 | 中 | 中 | 柜控板本地缓存+断线重连机制 |
+| 仓控板ID寻址冲突 | 低 | 高 | 拨码开关硬件防呆+上电自检+重复ID告警 |
+| 元器件供货延迟 | 中 | 中 | 提前采购,备选供应商方案 |
+| 温控传感器精度不足 | 低 | 中 | 选型测试验证,软件滤波处理 |
+| 指示灯亮度/视角不足 | 低 | 中 | 提前打样测试确认 |
+| 电池握手失败率偏高 | 中 | 高 | 增加重试机制,完善异常处理 |
+| PCB设计缺陷需改版 | 中 | 高 | 预留测试点,第一版预投少量验证 |
+| 加热风扇温控失效 | 低 | 中 | 增加硬件超温保护,双路温控冗余 |
+| 电磁泵继电器粘连 | 低 | 中 | 选用高品质继电器,定期巡检 |
+| 门锁频繁动作损坏 | 中 | 中 | 选用工业级电控锁,预留手动开门方式 |
+| BMS多协议兼容性 | 中 | 高 | 协议适配层隔离,各协议独立实现,平台可远程回退 |
+| 喷淋电磁阀误触发 | 低 | 中 | 硬件滤波+软件确认延时,防误喷 |
+
+---
+
+## 7. 测试策略
+
+### 7.1 测试阶段对应
+
+| 阶段 | 测试类型 | 说明 |
+|------|---------|------|
+| 第5-6周 | 单元测试 | 硬件模块+软件模块单独测试 |
+| 第7-9周 | 集成测试 | 模块间对接测试,协议联调 |
+| 第10-11周 | 系统测试 | 全功能测试,模拟实际场景 |
+
+### 7.2 核心测试用例
+
+| 编号 | 测试项 | 预期结果 |
+|------|--------|---------|
+| TC01 | 36路独立充电同时运行 | 各舱互不干扰,电源正常输出 |
+| TC02 | 指示灯状态切换 | 各状态切换准确,延迟≤0.5s |
+| TC03 | 平台禁用/解除 | 禁用后红灯常亮,禁止充电;解除后恢复绿灯常亮 |
+| TC04 | BMS数据读取 | SOC/电压/温度/告警数据准确 |
+| TC05 | 电源参数控制 | 电压/电流设置准确,开关控制正常 |
+| TC06 | 电池握手流程 | 插入→读取→上传→校验→充电启动,流程完整 |
+| TC07 | 双模式充电控制 | 平台模式/柜控模式均正常 |
+| TC08 | 仓控板ID识别 | 拨码ID1-6准确识别,通讯寻址无误 |
+| TC09 | 仓控-电源独立通讯 | 各仓控板与对应电源通讯稳定 |
+| TC10 | 烟感触发保护 | 全局断电,红灯切换,告警上传 |
+| TC11 | 浸水触发保护 | 同上 |
+| TC12 | 过充保护 | SOC≥98%自动断电 |
+| TC13 | 过温保护 | 温度>55℃自动断电 |
+| TC14 | 4G断线重连 | 断线后缓存数据,重连后补偿上传 |
+| TC15 | 电池不合规红灯闪烁 | 拔掉电池后自动恢复绿灯常亮 |
+| TC16 | 加热风扇温控 | 环境温度<0°C自动启动,≥25°C自动停止 |
+| TC17 | 电磁泵远程控制 | 平台下发通/断指令,继电器正确响应 |
+| TC18 | 加热风扇滞回控制 | 温度在0-25°C之间保持当前状态不抖动 |
+| TC19 | 扫码开门 | 平台下发开门指令,门锁动作,门磁反馈正确 |
+| TC20 | 关门超时报警 | 开门后超过设置阈值未关,上传告警 |
+| TC21 | 烟感联动喷淋 | 烟感触发→接触器断开+喷淋电磁阀打开+告警上传 |
+| TC22 | BMS协议切换 | 平台切换星恒/天能/无协议,仓控板正确切换通讯方式 |
+| TC23 | 无协议充电 | 固定电压电流输出,低于停止电流阈值自动断电 |
+| TC24 | OTA固件升级 | 柜控板+仓控板固件在线升级,升级后正常运行 |
+| TC25 | 高温报警阈值可配置 | 平台设置不同阈值,仓体超温触发告警 |
+| TC26 | 电表耗电量采集 | 柜控板按DL/T645读取电表数据,上报平台显示正确 |
diff --git a/docs/附件-开发需求规格说明书.html b/docs/附件-开发需求规格说明书.html
new file mode 100644
index 0000000..00cdb5b
--- /dev/null
+++ b/docs/附件-开发需求规格说明书.html
@@ -0,0 +1,534 @@
+附件-开发需求规格说明书附件:开发需求规格说明书
+所属合同: 36仓体直流电瓶车充电柜 开发合同
+合同编号: ________________
+
+第1章 柜控主板(ID128)硬件规格
+1.1 基本参数
+
+
+
+| 项目 |
+参数 |
+
+
+
+| 板卡数量 |
+3块(含1块备板) |
+
+
+| MCU |
+内置MCU控制器,支持OTA固件升级 |
+
+
+| 供电 |
+12V直流输入(预接触器,常供电) |
+
+
+| 后备电源 |
+后备电池自动切换(可选),维持板子供电与4G通讯 |
+
+
+| 工作温度 |
+-10℃ ~ +70℃ |
+
+
+| 工作湿度 |
+10% ~ 90% |
+
+
+| PCB |
+双面板/四层板,根据设计复杂度确定 |
+
+
+1.2 接口规格
+
+
+
+| 接口 |
+规格 |
+
+
+
+| 485接口 |
+1路隔离RS485,与6个仓控板通讯,光耦隔离保护,波特率9600 |
+
+
+| 485电表接口 |
+1路隔离RS485,接DL/T645电表,读取耗电量数据 |
+
+
+| 4G模块 |
+Air780E,自定义TCP协议长连接 |
+
+
+| 烟感接口 |
+1路开关量输入,中断触发 |
+
+
+| 浸水接口 |
+1路开关量输入,中断触发 |
+
+
+| 倾倒检测 |
+1路开关量输入,中断触发 |
+
+
+| 环境温控接口 |
+1路,适配NTC/DS18B20 |
+
+
+| 门锁控制 |
+1路MOS管/三极管驱动输出 + 1路门磁反馈输入,控制柜体电控锁并检测锁状态 |
+
+
+| 接触器控制 |
+1路继电器输出,控制常开接触器(默认断开,给信号吸合) |
+
+
+| 喷淋电磁阀 |
+1路继电器输出,烟感触发时自动打开 |
+
+
+| 加热风扇继电器 |
+1路独立继电器输出,220V/10A |
+
+
+| 电磁泵继电器 |
+1路继电器输出 |
+
+
+| 接触器按钮 |
+2个物理按钮:合闸按钮(吸合接触器)、分闸按钮(断开接触器) |
+
+
+| 存储 |
+Flash,存储BMS类型/报警阈值/无协议充电参数 |
+
+
+1.3 供电时序
+
+- 主电源12V预接触器接入,柜控板常供电
+- 后备电池(可选)在380V断电时自动切换,维持板子供电与远程通讯
+- 接触器默认断开,上电后自动吸合恢复供电
+- 平台可远程下发恢复指令吸合接触器
+
+
+第2章 仓控主板(ID1-6)硬件规格
+2.1 基本参数
+
+
+
+| 项目 |
+参数 |
+
+
+
+| 板卡数量 |
+10块(含4块备板) |
+
+
+| MCU |
+内置MCU控制器,支持OTA固件升级 |
+
+
+| 供电 |
+由柜控板统一供电(12V) |
+
+
+| 工作温度 |
+-10℃ ~ +70℃ |
+
+
+| ID设置 |
+4位拨码开关,设置485从站ID(1-6) |
+
+
+2.2 接口规格
+
+
+
+| 接口 |
+规格 |
+
+
+
+| 485接口×3 |
+全部隔离:1路接柜控板(ID128)、1路接BMS总线(6仓体共用)、1路接所属电源模块 |
+
+
+| 光耦隔离 |
+3路485均做光耦隔离保护 |
+
+
+| 温控接口 |
+6路,适配NTC/DS18B20(可选) |
+
+
+| 指示灯接口 |
+6路,支持绿灯/红灯控制 |
+
+
+2.3 物料清单(乙方供)
+
+
+
+| 物料 |
+数量 |
+说明 |
+
+
+
+| 4G模块 |
+3个 |
+Air780E(用于柜控板) |
+
+
+| 光耦隔离芯片 |
+100个 |
+485隔离用 |
+
+
+| 状态指示灯 |
+50个 |
+红绿双色 |
+
+
+| 拨码开关 |
+10个 |
+4位 |
+
+
+| PCB打样及焊接辅料 |
+1批 |
+含钢网/样板焊接 |
+
+
+
+第3章 MCU固件需求
+3.1 柜控板固件(1个工程)
+
+
+
+| 功能 |
+说明 |
+
+
+
+| 485主站通讯 |
+与仓控板ID1-6双向寻址通讯,响应延迟≤0.5s |
+
+
+| 4G TCP通讯 |
+与后台平台自定义TCP长连接,含心跳/断线重连/数据缓存补偿 |
+
+
+| 指令转发 |
+接收平台充电控制/门锁/配置等指令,转发至对应仓控板 |
+
+
+| 安全监测联动 |
+烟感/浸水/倾倒检测,触发后断开接触器+打开喷淋电磁阀 |
+
+
+| 电表采集 |
+通过独立485接口按DL/T645协议读取电表耗电量,定时上报平台 |
+
+
+| 辅助设备控制 |
+加热风扇自动温控(<0°C开/≥25°C停)、电磁泵远程通断 |
+
+
+| 门锁控制 |
+接收平台开门指令,直接控制柜体电控锁 |
+
+
+| OTA升级 |
+接收平台固件包,本地升级或分片转发仓控板 |
+
+
+| 配置管理 |
+BMS协议类型、报警阈值、无协议充电参数的存储与下发 |
+
+
+3.2 仓控板固件(1个工程)
+
+
+
+| 功能 |
+说明 |
+
+
+
+| 485从站通讯 |
+响应柜控板指令,按拨码ID(1-6)寻址 |
+
+
+| BMS多协议通讯 |
+按配置协议轮询6仓体电池数据:星恒Modbus-RTU/天能自定义/无协议跳过 |
+
+
+| 1TN电源协议 |
+与所属充电电源独立485通讯,电压/电流/开关控制(电源自带通断) |
+
+
+| 充电控制逻辑 |
+平台/柜控双模式,含电池握手/充满判断/无协议充电/低电流自动停止 |
+
+
+| 状态采集与指示 |
+6路红绿LED指示灯控制 + 6路温控采集(可选) |
+
+
+| OTA升级与ID管理 |
+接收固件包校验写入,拨码开关读取识别 |
+
+
+
+第4章 后台管理系统需求
+4.1 功能模块
+
+
+
+| 模块 |
+功能要求 |
+
+
+
+| 设备管理 |
+实时监控、远程重启、参数配置、OTA固件升级(柜控板+仓控板) |
+
+
+| 仓体控制 |
+独立电流/电压设置、禁用/解除、状态查看、远程开门 |
+
+
+| BMS协议配置 |
+设置当前柜体BMS协议类型(星恒/天能/无协议充电) |
+
+
+| 无协议充电参数配置 |
+设置固定充电电压/电流/停止电流阈值 |
+
+
+| 充电控制模式配置 |
+平台/柜控模式切换、特殊ID管理 |
+
+
+| 能耗管理 |
+实时显示/统计电表耗电量数据,按日/月/年生成报表 |
+
+
+| 用户管理 |
+注册/登录、权限分配(管理员/普通/骑手) |
+
+
+| 数据统计 |
+充电量、使用率、故障率、温控数据、告警统计、能耗统计 |
+
+
+| 故障管理 |
+故障记录、工单分配、处理跟踪、解除禁用 |
+
+
+| 报警阈值管理 |
+关门超时阈值配置、高温报警阈值配置 |
+
+
+| TCP连接管理 |
+连接状态监控、心跳检测、断连告警 |
+
+
+| 辅助设备控制 |
+电磁泵远程通断控制、加热风扇状态监控 |
+
+
+4.2 数据模型
+
+
+
+| 实体 |
+字段 |
+
+
+
+| 充电柜 |
+id/名称/位置/状态/BMS协议类型/无协议充电参数/报警阈值/总耗电量 |
+
+
+| 仓体 |
+所属电源/所属仓控板ID/状态/电压电流设置/指示灯状态 |
+
+
+| 仓控板 |
+ID/状态/固件版本/OTA进度/6路温控(可选) |
+
+
+| 柜控板 |
+ID/状态/固件版本/OTA进度/TCP状态/4G信号/加热风扇/电磁泵/喷淋/电表耗电量 |
+
+
+| 充电记录 |
+仓体ID/开始结束时间/充电量/最高温度/状态 |
+
+
+| 故障记录 |
+主板ID/故障类型/故障码/时间/处理状态/等级 |
+
+
+
+第5章 微信小程序需求
+微信小程序为后台管理系统的移动端版本,实现后台核心功能的移动化操作。
+5.1 功能页面
+
+
+
+| 页面 |
+功能要求 |
+
+
+
+| 首页 |
+充电柜概览仪表盘:在线状态、总耗电量、今日充电量、待处理告警数 |
+
+
+| 设备管理 |
+查看柜控板/仓控板状态、仓体列表及状态、温控数据、电表读数 |
+
+
+| 远程控制 |
+仓体禁用/解除、远程开门、电磁泵通断、接触器合闸/分闸 |
+
+
+| 告警管理 |
+实时告警推送、告警列表查看与确认处理 |
+
+
+| 数据统计 |
+充电量/能耗/告警统计图表(日/月/年) |
+
+
+| 个人中心 |
+账号信息、修改密码、操作日志 |
+
+
+
+第6章 通讯协议栈
+6.1 协议列表
+
+
+
+| 协议 |
+说明 |
+
+
+
+| 电源1TN RS485 |
+与充电电源通讯,电压/电流设置、状态查询、故障读取 |
+
+
+| 星恒BMS Modbus-RTU |
+读取电池SOC/电压/温度/告警,地址0x16 |
+
+
+| 天能485协议 |
+适配天能电池自定义RS485协议 |
+
+
+| 电表DL/T645 |
+读取柜体总耗电量,定时采集上报 |
+
+
+| 仓控-柜控485协议 |
+自定义帧结构(帧头/ID/功能码/长度/数据/CRC16),14个功能码 |
+
+
+| 柜控-平台TCP协议 |
+自定义TCP长连接,含心跳/数据上报/指令下发/OTA |
+
+
+6.2 仓控-柜控485功能码表
+
+
+
+| 码 |
+指令 |
+方向 |
+
+
+
+| 0x01 |
+上传BMS数据 |
+仓控→柜控 |
+
+
+| 0x02 |
+上传温控数据 |
+仓控→柜控 |
+
+
+| 0x03 |
+上传电源状态 |
+仓控→柜控 |
+
+
+| 0x06 |
+上传OTA状态 |
+仓控→柜控 |
+
+
+| 0x82 |
+电源参数设置 |
+柜控→仓控 |
+
+
+| 0x83 |
+指示灯控制 |
+柜控→仓控 |
+
+
+| 0x84 |
+查询指令 |
+柜控→仓控 |
+
+
+| 0x85 |
+平台禁用 |
+柜控→仓控 |
+
+
+| 0x86 |
+模式切换 |
+平台→柜控 |
+
+
+| 0x87 |
+接触器控制 |
+平台→柜控 |
+
+
+| 0x89 |
+OTA升级 |
+柜控→仓控 |
+
+
+| 0x8A |
+BMS类型配置 |
+柜控→仓控 |
+
+
+| 0x8B |
+报警阈值配置 |
+柜控→仓控 |
+
+
+
\ No newline at end of file
diff --git a/docs/附件-开发需求规格说明书.pdf b/docs/附件-开发需求规格说明书.pdf
new file mode 100644
index 0000000..d1b2b0d
Binary files /dev/null and b/docs/附件-开发需求规格说明书.pdf differ
diff --git a/docs/附件一-硬件规格.docx b/docs/附件一-硬件规格.docx
new file mode 100644
index 0000000..64fb1c3
Binary files /dev/null and b/docs/附件一-硬件规格.docx differ
diff --git a/docs/附件一-硬件规格.md b/docs/附件一-硬件规格.md
new file mode 100644
index 0000000..29d9800
--- /dev/null
+++ b/docs/附件一-硬件规格.md
@@ -0,0 +1,108 @@
+# 附件一:硬件规格
+
+**所属合同:** 36仓体直流电瓶车充电柜 开发合同
+**合同编号:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+---
+
+## 第1章 柜控主板(ID128)硬件规格
+
+### 1.1 基本参数
+
+| 项目 | 参数 |
+|------|------|
+| 板卡数量 | 3块(含1块备板) |
+| MCU | 内置MCU控制器,支持OTA固件升级 |
+| 供电 | 12V直流输入(预接触器,常供电) |
+| 后备电源 | 后备电池自动切换(可选),维持板子供电与4G通讯 |
+| 工作温度 | -10℃ ~ +70℃ |
+| 工作湿度 | 10% ~ 90% |
+| PCB | 双面板/四层板,根据设计复杂度确定 |
+
+### 1.2 接口规格
+
+| 接口 | 规格 |
+|------|------|
+| 485接口 | 1路隔离RS485,与6个仓控板通讯,光耦隔离保护,波特率9600 |
+| 485电表接口 | 1路隔离RS485,接DL/T645电表,读取耗电量数据 |
+| 4G模块 | Air780E,自定义TCP协议长连接 |
+| 烟感接口 | 1路开关量输入,中断触发 |
+| 浸水接口 | 1路开关量输入,中断触发 |
+| 倾倒检测 | 1路开关量输入,中断触发 |
+| 环境温控接口 | 1路,适配NTC/DS18B20 |
+| 门锁控制 | 1路MOS管/三极管驱动输出 + 1路门磁反馈输入,控制柜体电控锁并检测锁状态 |
+| 接触器控制 | 1路继电器输出,控制常开接触器(默认断开,给信号吸合) |
+| 喷淋电磁阀 | 1路继电器输出,烟感触发时自动打开 |
+| 加热风扇继电器 | 1路独立继电器输出,220V/10A |
+| 电磁泵继电器 | 1路继电器输出 |
+| 接触器按钮 | 2个物理按钮:合闸按钮(吸合接触器)、分闸按钮(断开接触器) |
+| 存储 | Flash,存储BMS类型/报警阈值/无协议充电参数 |
+
+### 1.3 供电时序
+
+- 主电源12V预接触器接入,柜控板常供电
+- 后备电池(可选)在380V断电时自动切换,维持板子供电与远程通讯
+- 接触器默认断开,上电后自动吸合恢复供电
+- 平台可远程下发恢复指令吸合接触器
+
+---
+
+## 第2章 仓控主板(ID1-6)硬件规格
+
+### 2.1 基本参数
+
+| 项目 | 参数 |
+|------|------|
+| 板卡数量 | 10块(含4块备板) |
+| MCU | 内置MCU控制器,支持OTA固件升级 |
+| 供电 | 由柜控板统一供电(12V) |
+| 工作温度 | -10℃ ~ +70℃ |
+| ID设置 | 4位拨码开关,设置485从站ID(1-6) |
+
+### 2.2 接口规格
+
+| 接口 | 规格 |
+|------|------|
+| 485接口×3 | 全部隔离:1路接柜控板(ID128)、1路接BMS总线(6仓体共用)、1路接所属电源模块 |
+| 光耦隔离 | 3路485均做光耦隔离保护 |
+| 温控接口 | 6路,适配NTC/DS18B20(可选) |
+| 指示灯接口 | 6路,支持绿灯/红灯控制 |
+
+### 2.3 物料清单(乙方供)
+
+| 物料 | 数量 | 说明 |
+|------|------|------|
+| 4G模块 | 3个 | Air780E(用于柜控板) |
+| 光耦隔离芯片 | 100个 | 485隔离用 |
+| 状态指示灯 | 50个 | 红绿双色 |
+| 拨码开关 | 10个 | 4位 |
+| PCB打样及焊接辅料 | 1批 | 含钢网/样板焊接 |
+
+---
+
+## 第3章 MCU固件需求
+
+### 3.1 柜控板固件(1个工程)
+
+| 功能 | 说明 |
+|------|------|
+| 485主站通讯 | 与仓控板ID1-6双向寻址通讯,响应延迟≤0.5s |
+| 4G TCP通讯 | 与后台平台自定义TCP长连接,含心跳/断线重连/数据缓存补偿 |
+| 指令转发 | 接收平台充电控制/门锁/配置等指令,转发至对应仓控板 |
+| 安全监测联动 | 烟感/浸水/倾倒检测,触发后断开接触器+打开喷淋电磁阀 |
+| 电表采集 | 通过独立485接口按DL/T645协议读取电表耗电量,定时上报平台 |
+| 辅助设备控制 | 加热风扇自动温控(<0°C开/≥25°C停)、电磁泵远程通断 |
+| 门锁控制 | 接收平台开门指令,直接控制柜体电控锁 |
+| OTA升级 | 接收平台固件包,本地升级或分片转发仓控板 |
+| 配置管理 | BMS协议类型、报警阈值、无协议充电参数的存储与下发 |
+
+### 3.2 仓控板固件(1个工程)
+
+| 功能 | 说明 |
+|------|------|
+| 485从站通讯 | 响应柜控板指令,按拨码ID(1-6)寻址 |
+| BMS多协议通讯 | 按配置协议轮询6仓体电池数据:星恒Modbus-RTU/天能自定义/无协议跳过 |
+| 1TN电源协议 | 与所属充电电源独立485通讯,电压/电流/开关控制(电源自带通断) |
+| 充电控制逻辑 | 平台/柜控双模式,含电池握手/充满判断/无协议充电/低电流自动停止 |
+| 状态采集与指示 | 6路红绿LED指示灯控制 + 6路温控采集(可选) |
+| OTA升级与ID管理 | 接收固件包校验写入,拨码开关读取识别 |
diff --git a/docs/附件三-交付与验收标准.docx b/docs/附件三-交付与验收标准.docx
new file mode 100644
index 0000000..f451f4a
Binary files /dev/null and b/docs/附件三-交付与验收标准.docx differ
diff --git a/docs/附件三-交付与验收标准.md b/docs/附件三-交付与验收标准.md
new file mode 100644
index 0000000..273a878
--- /dev/null
+++ b/docs/附件三-交付与验收标准.md
@@ -0,0 +1,62 @@
+# 附件三:交付与验收标准
+
+**所属合同:** 36仓体直流电瓶车充电柜 开发合同
+**合同编号:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+---
+
+## 1. 交付物清单
+
+| 编号 | 交付物 | 形式 | 数量 |
+|------|--------|------|------|
+| 1 | 柜控主板(含固件) | 硬件成品 | 3块(含1块备板) |
+| 2 | 仓控主板(含固件) | 硬件成品 | 10块(含4块备板) |
+| 3 | 后台管理系统 | 编译部署包 | 1份 |
+| 4 | 微信小程序 | 编译发布包 | 1份 |
+| 5 | 技术开发文档 | 电子版 | 1份 |
+| 6 | 用户操作手册 | 电子版 | 1份 |
+| 7 | 运维手册 | 电子版 | 1份 |
+| 8 | 测试报告 | 电子版 | 1份 |
+
+注:充电柜柜体、充电电源及外部器件(风扇/泵/阀/锁/传感器等)由甲方提供,不在乙方交付范围内。**本合同不交付任何源代码。**
+
+## 2. 验收标准
+
+| 验收项 | 标准 |
+|--------|------|
+| 硬件验收 | 各接口功能正常,通讯稳定,电气参数符合设计要求 |
+| 固件验收 | 所有功能码响应正确,通讯延迟≤0.5s,无死机/异常重启 |
+| 后台验收 | 各管理功能正常,数据展示准确,操作响应≤2s |
+| 小程序验收 | 扫码充电流程完整,状态展示与硬件一致 |
+| 整机验收 | 乙方到甲方现场,将板子安装至甲方提供的柜体中完成36仓同步充电测试及安全防护功能测试 |
+
+## 3. 通讯协议栈
+
+### 3.1 协议列表
+
+| 协议 | 说明 |
+|------|------|
+| 电源1TN RS485 | 与充电电源通讯,电压/电流设置、状态查询、故障读取 |
+| 星恒BMS Modbus-RTU | 读取电池SOC/电压/温度/告警,地址0x16 |
+| 天能485协议 | 适配天能电池自定义RS485协议 |
+| 电表DL/T645 | 读取柜体总耗电量,定时采集上报 |
+| 仓控-柜控485协议 | 自定义帧结构(帧头/ID/功能码/长度/数据/CRC16),14个功能码 |
+| 柜控-平台TCP协议 | 自定义TCP长连接,含心跳/数据上报/指令下发/OTA |
+
+### 3.2 仓控-柜控485功能码表
+
+| 码 | 指令 | 方向 |
+|----|------|------|
+| 0x01 | 上传BMS数据 | 仓控→柜控 |
+| 0x02 | 上传温控数据 | 仓控→柜控 |
+| 0x03 | 上传电源状态 | 仓控→柜控 |
+| 0x06 | 上传OTA状态 | 仓控→柜控 |
+| 0x82 | 电源参数设置 | 柜控→仓控 |
+| 0x83 | 指示灯控制 | 柜控→仓控 |
+| 0x84 | 查询指令 | 柜控→仓控 |
+| 0x85 | 平台禁用 | 柜控→仓控 |
+| 0x86 | 模式切换 | 平台→柜控 |
+| 0x87 | 接触器控制 | 平台→柜控 |
+| 0x89 | OTA升级 | 柜控→仓控 |
+| 0x8A | BMS类型配置 | 柜控→仓控 |
+| 0x8B | 报警阈值配置 | 柜控→仓控 |
diff --git a/docs/附件二-软件规格.docx b/docs/附件二-软件规格.docx
new file mode 100644
index 0000000..d442859
Binary files /dev/null and b/docs/附件二-软件规格.docx differ
diff --git a/docs/附件二-软件规格.md b/docs/附件二-软件规格.md
new file mode 100644
index 0000000..4f336bf
--- /dev/null
+++ b/docs/附件二-软件规格.md
@@ -0,0 +1,53 @@
+# 附件二:软件规格
+
+**所属合同:** 36仓体直流电瓶车充电柜 开发合同
+**合同编号:** \_\_\_\_\_\_\_\_\_\_\_\_\_\_\_\_
+
+---
+
+## 第1章 后台管理系统需求
+
+### 1.1 功能模块
+
+| 模块 | 功能要求 |
+|------|---------|
+| 设备管理 | 实时监控、远程重启、参数配置、OTA固件升级(柜控板+仓控板) |
+| 仓体控制 | 独立电流/电压设置、禁用/解除、状态查看、远程开门 |
+| BMS协议配置 | 设置当前柜体BMS协议类型(星恒/天能/无协议充电) |
+| 无协议充电参数配置 | 设置固定充电电压/电流/停止电流阈值 |
+| 充电控制模式配置 | 平台/柜控模式切换、特殊ID管理 |
+| 能耗管理 | 实时显示/统计电表耗电量数据,按日/月/年生成报表 |
+| 用户管理 | 注册/登录、权限分配(管理员/普通/骑手) |
+| 数据统计 | 充电量、使用率、故障率、温控数据、告警统计、能耗统计 |
+| 故障管理 | 故障记录、工单分配、处理跟踪、解除禁用 |
+| 报警阈值管理 | 关门超时阈值配置、高温报警阈值配置 |
+| TCP连接管理 | 连接状态监控、心跳检测、断连告警 |
+| 辅助设备控制 | 电磁泵远程通断控制、加热风扇状态监控 |
+
+### 1.2 数据模型
+
+| 实体 | 字段 |
+|------|------|
+| 充电柜 | id/名称/位置/状态/BMS协议类型/无协议充电参数/报警阈值/总耗电量 |
+| 仓体 | 所属电源/所属仓控板ID/状态/电压电流设置/指示灯状态 |
+| 仓控板 | ID/状态/固件版本/OTA进度/6路温控(可选) |
+| 柜控板 | ID/状态/固件版本/OTA进度/TCP状态/4G信号/加热风扇/电磁泵/喷淋/电表耗电量 |
+| 充电记录 | 仓体ID/开始结束时间/充电量/最高温度/状态 |
+| 故障记录 | 主板ID/故障类型/故障码/时间/处理状态/等级 |
+
+---
+
+## 第2章 微信小程序需求
+
+微信小程序为后台管理系统的移动端版本。
+
+### 2.1 功能页面
+
+| 页面 | 功能要求 |
+|------|---------|
+| 首页 | 充电柜概览仪表盘:在线状态、总耗电量、今日充电量、待处理告警数 |
+| 设备管理 | 查看柜控板/仓控板状态、仓体列表及状态、温控数据、电表读数 |
+| 远程控制 | 仓体禁用/解除、远程开门、电磁泵通断、接触器合闸/分闸 |
+| 告警管理 | 实时告警推送、告警列表查看与确认处理 |
+| 数据统计 | 充电量/能耗/告警统计图表(日/月/年) |
+| 个人中心 | 账号信息、修改密码、操作日志 |
diff --git a/software/Cargo.lock b/software/Cargo.lock
new file mode 100644
index 0000000..9474af5
--- /dev/null
+++ b/software/Cargo.lock
@@ -0,0 +1,3118 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "api-server"
+version = "0.1.0"
+dependencies = [
+ "argon2",
+ "axum",
+ "bytes",
+ "chrono",
+ "dotenvy",
+ "futures",
+ "hex",
+ "jsonwebtoken",
+ "rand",
+ "redis",
+ "rust_xlsxwriter",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+]
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "backon"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
+dependencies = [
+ "fastrand",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2 0.6.4",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "bitflags",
+ "libc",
+ "plain",
+ "redox_syscall 0.8.1",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "openssl"
+version = "0.10.81"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "pms-device-server"
+version = "0.1.0"
+dependencies = [
+ "bytes",
+ "chrono",
+ "dotenvy",
+ "futures",
+ "redis",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "pms-server"
+version = "0.1.0"
+dependencies = [
+ "argon2",
+ "axum",
+ "bytes",
+ "chrono",
+ "dotenvy",
+ "futures",
+ "hex",
+ "jsonwebtoken",
+ "rand",
+ "redis",
+ "rust_xlsxwriter",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "redis"
+version = "0.28.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e37ec3fd44bea2ec947ba6cc7634d7999a6590aca7c35827c250bc0de502bda6"
+dependencies = [
+ "arc-swap",
+ "backon",
+ "bytes",
+ "combine",
+ "futures-channel",
+ "futures-util",
+ "itoa",
+ "num-bigint",
+ "percent-encoding",
+ "pin-project-lite",
+ "ryu",
+ "sha1_smol",
+ "socket2 0.5.10",
+ "tokio",
+ "tokio-util",
+ "url",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rust_xlsxwriter"
+version = "0.80.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "442eafa04d985ae671e027481e07a5b70fdb1b2cb5e46d9e074b67ca98e01a0a"
+dependencies = [
+ "zip",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "schannel"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64",
+ "bytes",
+ "chrono",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rustls",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "chrono",
+ "crc",
+ "digest",
+ "dotenvy",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rand",
+ "rsa",
+ "serde",
+ "sha1",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "chrono",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "chrono",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.9.4",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e48db7b415311b615f910b3dcaa4557bcd4bf1982379c95c223fd8c2a20e210"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.6.4",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.8",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zip"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "crossbeam-utils",
+ "displaydoc",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "thiserror",
+ "zopfli",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
diff --git a/software/api-server/.env.production b/software/api-server/.env.production
new file mode 100644
index 0000000..25c0c4a
--- /dev/null
+++ b/software/api-server/.env.production
@@ -0,0 +1,15 @@
+# MySQL 连接(pms_dev 库由迁移自动创建)
+DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms_dev
+
+# Redis 连接(生产 db5)
+REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/5
+
+# HTTP 服务
+HOST=0.0.0.0
+PORT=3000
+
+# JWT
+JWT_SECRET=pms-dev-secret-change-me-in-production
+
+# 节点心跳超时(秒)
+NODE_HEARTBEAT_TIMEOUT=180
diff --git a/software/api-server/.env.test b/software/api-server/.env.test
new file mode 100644
index 0000000..1354ee7
--- /dev/null
+++ b/software/api-server/.env.test
@@ -0,0 +1,15 @@
+# MySQL 连接(pms_dev 库由迁移自动创建)
+DATABASE_URL=mysql://root:Hbhyg731024%40@10.8.0.252:3306/pms_dev
+
+# Redis 连接(测试 db6)
+REDIS_URL=redis://:Hbhyg731024%40@10.8.0.252:6379/6
+
+# HTTP 服务
+HOST=0.0.0.0
+PORT=3000
+
+# JWT
+JWT_SECRET=pms-test-secret
+
+# 节点心跳超时(秒)
+NODE_HEARTBEAT_TIMEOUT=180
diff --git a/software/api-server/Cargo.lock b/software/api-server/Cargo.lock
new file mode 100644
index 0000000..6126885
--- /dev/null
+++ b/software/api-server/Cargo.lock
@@ -0,0 +1,2695 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "api-server"
+version = "0.1.0"
+dependencies = [
+ "argon2",
+ "axum",
+ "bytes",
+ "chrono",
+ "dotenvy",
+ "futures",
+ "hex",
+ "jsonwebtoken",
+ "rand",
+ "redis",
+ "rust_xlsxwriter",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx",
+ "thiserror",
+ "tokio",
+ "tokio-util",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+]
+
+[[package]]
+name = "arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "backon"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
+dependencies = [
+ "fastrand",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "memchr",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "bitflags",
+ "libc",
+ "plain",
+ "redox_syscall 0.8.1",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "redis"
+version = "0.28.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e37ec3fd44bea2ec947ba6cc7634d7999a6590aca7c35827c250bc0de502bda6"
+dependencies = [
+ "arc-swap",
+ "backon",
+ "bytes",
+ "combine",
+ "futures-channel",
+ "futures-util",
+ "itoa",
+ "num-bigint",
+ "percent-encoding",
+ "pin-project-lite",
+ "ryu",
+ "sha1_smol",
+ "socket2 0.5.10",
+ "tokio",
+ "tokio-util",
+ "url",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rust_xlsxwriter"
+version = "0.80.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "442eafa04d985ae671e027481e07a5b70fdb1b2cb5e46d9e074b67ca98e01a0a"
+dependencies = [
+ "zip",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha1_smol"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simple_asn1"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror",
+ "time",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64",
+ "bytes",
+ "chrono",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rustls",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "chrono",
+ "crc",
+ "digest",
+ "dotenvy",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rand",
+ "rsa",
+ "serde",
+ "sha1",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "chrono",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "chrono",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "time"
+version = "0.3.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e48db7b415311b615f910b3dcaa4557bcd4bf1982379c95c223fd8c2a20e210"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.6.4",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "http",
+ "pin-project-lite",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "0.26.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
+dependencies = [
+ "webpki-roots 1.0.8",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zip"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "crossbeam-utils",
+ "displaydoc",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "thiserror",
+ "zopfli",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+
+[[package]]
+name = "zopfli"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
diff --git a/software/api-server/Cargo.toml b/software/api-server/Cargo.toml
new file mode 100644
index 0000000..3311214
--- /dev/null
+++ b/software/api-server/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "api-server"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+axum = "0.8"
+tokio = { version = "1", features = ["full"] }
+sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "mysql", "chrono"] }
+redis = { version = "0.28", features = ["tokio-comp", "connection-manager"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+thiserror = "2"
+chrono = { version = "0.4", features = ["serde"] }
+tower-http = { version = "0.6", features = ["cors"] }
+uuid = { version = "1", features = ["v4"] }
+sha2 = "0.10"
+tokio-util = { version = "0.7", features = ["codec"] }
+bytes = "1"
+futures = "0.3"
+rand = "0.8"
+hex = "0.4"
+rust_xlsxwriter = "0.80"
+jsonwebtoken = "9"
+argon2 = "0.5"
+dotenvy = "0.15"
diff --git a/software/api-server/src/commands.rs b/software/api-server/src/commands.rs
new file mode 100644
index 0000000..2bb72c0
--- /dev/null
+++ b/software/api-server/src/commands.rs
@@ -0,0 +1,301 @@
+//! 设备指令下发 — 通过 Redis 队列向设备发送指令
+//!
+//! API 服务不直接持有 TCP 连接,而是通过 Redis LIST `device:cmd:{imei}`
+//! 推送指令,由设备服务消费并发送到设备。指令响应通过 `device:reply:{imei}`
+//! 队列返回,API 服务通过 `PendingCommands` 匹配 msg_id 完成异步等待。
+
+use std::collections::HashMap;
+use std::sync::Arc;
+use axum::extract::{Path, State};
+use axum::Json;
+use serde::Deserialize;
+use serde_json::{json, Value};
+use tokio::sync::{oneshot, Mutex};
+
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+use crate::protocol::ServerResponse;
+
+/// msg_id → oneshot Sender 映射表
+#[derive(Clone)]
+pub struct PendingCommands {
+ inner: Arc>>>,
+}
+
+impl PendingCommands {
+ pub fn new() -> Self {
+ Self { inner: Arc::new(Mutex::new(HashMap::new())) }
+ }
+
+ /// 注册等待中的指令
+ #[allow(dead_code)]
+ pub async fn insert(&self, msg_id: u32, tx: oneshot::Sender) {
+ self.inner.lock().await.insert(msg_id, tx);
+ }
+
+ /// 移除并返回指定 msg_id 的 sender(消费一次)
+ pub async fn remove(&self, msg_id: u32) -> Option> {
+ self.inner.lock().await.remove(&msg_id)
+ }
+}
+
+/// 设备服务节点信息
+#[derive(Debug, Clone)]
+pub struct NodeInfo {
+ pub node_id: String,
+ pub ip: String,
+ pub tcp_port: u16,
+ /// RFC3339 格式的最后心跳时间,从 Redis 读取
+ pub last_heartbeat: Option,
+}
+
+/// 节点注册表(Redis 存储,API 服务从 Redis 读取节点信息)
+///
+/// 设备服务通过 HSET 写入 `node:{node_id}` hash,
+/// API 服务通过此模块读取。Redis 是节点信息的单一数据源。
+#[derive(Clone)]
+pub struct NodeRegistry {
+ redis: redis::aio::ConnectionManager,
+}
+
+impl NodeRegistry {
+ pub fn new(redis: redis::aio::ConnectionManager) -> Self {
+ Self { redis }
+ }
+
+ fn node_key(node_id: &str) -> String {
+ format!("node:{}", node_id)
+ }
+
+ /// 注册节点(写入 Redis HSET)
+ pub async fn register(&self, node_id: &str, ip: &str, tcp_port: u16) {
+ let key = Self::node_key(node_id);
+ let now = chrono::Utc::now().to_rfc3339();
+ let mut conn = self.redis.clone();
+
+ let _: Result<(), _> = redis::cmd("HSET")
+ .arg(&key)
+ .arg("node_id").arg(node_id)
+ .arg("ip").arg(ip)
+ .arg("tcp_port").arg(tcp_port.to_string())
+ .arg("status").arg("online")
+ .arg("last_heartbeat").arg(&now)
+ .query_async(&mut conn)
+ .await;
+
+ // ponytail: HSET 不支持 EX,用 EXPIRE 设置 TTL
+ let _: Result<(), _> = redis::cmd("EXPIRE")
+ .arg(&key)
+ .arg(300_u64)
+ .query_async(&mut conn)
+ .await;
+ }
+
+ /// 移除节点(从 Redis 删除)
+ pub async fn remove(&self, node_id: &str) {
+ let key = Self::node_key(node_id);
+ let mut conn = self.redis.clone();
+ let _: Result<(), _> = redis::cmd("DEL")
+ .arg(&key)
+ .query_async(&mut conn)
+ .await;
+ }
+
+ /// 更新心跳时间戳
+ pub async fn heartbeat(&self, node_id: &str) -> bool {
+ let key = Self::node_key(node_id);
+ let mut conn = self.redis.clone();
+ let result: Result<(), _> = redis::cmd("HSET")
+ .arg(&key)
+ .arg("last_heartbeat")
+ .arg(chrono::Utc::now().to_rfc3339())
+ .query_async(&mut conn)
+ .await;
+
+ // 心跳同时刷新 TTL
+ let _: Result<(), _> = redis::cmd("EXPIRE")
+ .arg(&key)
+ .arg(300_u64)
+ .query_async(&mut conn)
+ .await;
+
+ result.is_ok()
+ }
+
+ /// 列出所有节点(从 Redis 读取,使用 SCAN 替代 KEYS 避免阻塞)
+ pub async fn list(&self) -> Vec {
+ let mut conn = self.redis.clone();
+ let keys = self.scan_keys(&mut conn, "node:*").await;
+
+ let mut nodes = Vec::with_capacity(keys.len());
+ for key in keys {
+ let mut conn = self.redis.clone();
+ let info: HashMap = redis::cmd("HGETALL")
+ .arg(&key)
+ .query_async(&mut conn)
+ .await
+ .unwrap_or_default();
+
+ let node_id = info.get("node_id").cloned().unwrap_or_default();
+ if node_id.is_empty() {
+ continue; // ponytail: 跳过空记录
+ }
+
+ nodes.push(NodeInfo {
+ node_id,
+ ip: info.get("ip").cloned().unwrap_or_default(),
+ tcp_port: info.get("tcp_port").and_then(|p| p.parse().ok()).unwrap_or(0),
+ last_heartbeat: info.get("last_heartbeat").cloned(),
+ });
+ }
+ nodes
+ }
+
+ /// 使用 SCAN 命令安全地获取所有匹配模式的键(避免 KEYS 阻塞)
+ async fn scan_keys(&self, conn: &mut redis::aio::ConnectionManager, pattern: &str) -> Vec {
+ let mut keys = Vec::new();
+ let mut cursor = 0i64;
+ loop {
+ let (next_cursor, batch): (i64, Vec) = redis::cmd("SCAN")
+ .arg(cursor)
+ .arg("MATCH")
+ .arg(pattern)
+ .arg("COUNT")
+ .arg(100)
+ .query_async(conn)
+ .await
+ .unwrap_or_default();
+ keys.extend(batch);
+ if next_cursor == 0 {
+ break;
+ }
+ cursor = next_cursor;
+ }
+ keys
+ }
+
+ /// 检查设备是否在线(通过 Redis 中的在线标记)
+ pub async fn is_device_online(&self, imei: &str) -> bool {
+ let key = format!("device:online:{}", imei);
+ let mut conn = self.redis.clone();
+ let exists: Result = redis::cmd("EXISTS")
+ .arg(&key)
+ .query_async(&mut conn)
+ .await;
+ exists.unwrap_or(false)
+ }
+}
+
+/// 应用共享状态
+#[derive(Clone)]
+#[allow(dead_code)]
+pub struct AppState {
+ pub redis: redis::aio::ConnectionManager,
+ pub mysql: sqlx::MySqlPool,
+ pub pending_commands: PendingCommands,
+ pub nodes: NodeRegistry,
+}
+
+/// 指令下发请求体
+#[derive(Debug, Deserialize)]
+pub struct CommandRequest {
+ pub act: String,
+ #[serde(default)]
+ pub msg_id: u32,
+ pub sub_device_id: Option,
+ #[serde(flatten)]
+ pub params: Option,
+}
+
+/// 设备指令结构(序列化后推入 Redis)
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct DeviceCommand {
+ pub act: String,
+ pub msg_id: u32,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub dev_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub sub_device_id: Option,
+ /// 扩展参数(开门指令、充电参数等)
+ #[serde(flatten)]
+ pub params: Option,
+}
+
+impl DeviceCommand {
+ /// 序列化为 JSON 字符串(紧凑格式)。
+ pub fn to_json(&self) -> String {
+ serde_json::to_string(self).expect("DeviceCommand 序列化不应失败")
+ }
+}
+
+/// 向设备发送指令(通过 Redis 队列)
+pub async fn send_device_command(
+ redis: &redis::aio::ConnectionManager,
+ _dev_id: &str,
+ cmd: &DeviceCommand,
+) -> Result<(), String> {
+ let json_str = cmd.to_json();
+ let mut conn = redis.clone();
+ redis::cmd("LPUSH")
+ .arg("device:commands")
+ .arg(&json_str)
+ .query_async::<()>(&mut conn)
+ .await
+ .map_err(|e| format!("Redis 推入失败: {}", e))?;
+ Ok(())
+}
+
+/// 向设备发送响应(通过 Redis 队列)
+#[allow(dead_code)]
+pub async fn send_device_response(
+ redis: &redis::aio::ConnectionManager,
+ _dev_id: &str,
+ resp: &ServerResponse,
+) -> Result<(), String> {
+ let json_str = resp.to_json();
+ let mut conn = redis.clone();
+ redis::cmd("LPUSH")
+ .arg("device:commands")
+ .arg(&json_str)
+ .query_async::<()>(&mut conn)
+ .await
+ .map_err(|e| format!("Redis 推入失败: {}", e))?;
+ Ok(())
+}
+
+/// POST /api/devices/:dev_id/command — 向指定设备下发指令
+pub async fn send_command(
+ user: CurrentUser,
+ State(state): State,
+ Path(dev_id): Path,
+ Json(req): Json,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:operate")?;
+
+ let cmd = DeviceCommand {
+ act: req.act,
+ msg_id: req.msg_id,
+ dev_id: Some(dev_id.clone()),
+ sub_device_id: req.sub_device_id,
+ params: req.params,
+ };
+
+ send_device_command(&state.redis, &dev_id, &cmd)
+ .await
+ .map_err(AppError::Internal)?;
+
+ tracing::info!("[command] 已推入队列 dev_id={} act={}", dev_id, cmd.act);
+ Ok(Json(json!({ "suc": 1 })))
+}
+
+/// GET /api/devices/stats — 在线设备统计
+pub async fn device_stats(
+ user: CurrentUser,
+ State(state): State,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:view")?;
+ let nodes = state.nodes.list().await;
+ Ok(Json(json!({
+ "node_count": nodes.len(),
+ })))
+}
diff --git a/software/api-server/src/config.rs b/software/api-server/src/config.rs
new file mode 100644
index 0000000..c6327dd
--- /dev/null
+++ b/software/api-server/src/config.rs
@@ -0,0 +1,59 @@
+//! 应用配置管理
+//!
+//! 优先从环境变量加载,回退到默认值。与原始 server 保持一致的配置方式。
+
+/// 默认 JWT 密钥(开发环境用,生产环境必须通过环境变量覆盖)
+pub const JWT_SECRET_DEFAULT: &str = "pms-dev-secret-change-me-in-production";
+
+/// 用户默认密码(创建用户或重置密码时使用)
+pub const DEFAULT_PASSWORD: &str = "123456";
+
+/// 应用配置
+#[allow(dead_code)]
+pub struct Config {
+ pub database_url: String,
+ pub redis_url: String,
+ pub host: String,
+ pub port: u16,
+ /// JWT 签名密钥
+ pub jwt_secret: String,
+ /// 导出目录
+ pub export_dir: String,
+ /// 节点心跳超时(秒)
+ pub node_heartbeat_timeout: u64,
+}
+
+impl Config {
+ /// 从环境变量加载配置,优先读取 `.env` 文件
+ pub fn from_env() -> Self {
+ let _ = dotenvy::dotenv();
+
+ Self {
+ database_url: std::env::var("DATABASE_URL")
+ .expect("DATABASE_URL must be set"),
+ redis_url: std::env::var("REDIS_URL")
+ .expect("REDIS_URL must be set"),
+ host: std::env::var("HOST")
+ .unwrap_or_else(|_| "0.0.0.0".to_string()),
+ port: std::env::var("PORT")
+ .unwrap_or_else(|_| "3000".to_string())
+ .parse()
+ .expect("PORT must be a valid number"),
+ jwt_secret: std::env::var("JWT_SECRET")
+ .unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string()),
+ export_dir: std::env::var("EXPORT_DIR")
+ .unwrap_or_else(|_| "./exports".to_string()),
+ node_heartbeat_timeout: std::env::var("NODE_HEARTBEAT_TIMEOUT")
+ .unwrap_or_else(|_| "180".to_string())
+ .parse()
+ .expect("NODE_HEARTBEAT_TIMEOUT must be a valid number"),
+ }
+ }
+
+ /// 校验配置安全性
+ pub fn validate(&self) {
+ if self.jwt_secret == JWT_SECRET_DEFAULT {
+ tracing::warn!("使用默认 JWT 密钥,生产环境请设置 JWT_SECRET 环境变量");
+ }
+ }
+}
diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs
new file mode 100644
index 0000000..9057d0e
--- /dev/null
+++ b/software/api-server/src/db/migrate.rs
@@ -0,0 +1,406 @@
+//! 数据库迁移 — 自动建表
+
+use sqlx::MySqlPool;
+
+/// 执行数据库迁移,创建所有必要的数据表
+pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
+ let sql = r#"
+ CREATE TABLE IF NOT EXISTS organizations (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ name VARCHAR(100) NOT NULL 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 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) NOT NULL UNIQUE COMMENT '平台抽象ID,如10-00000000',
+ imei VARCHAR(15) NOT NULL UNIQUE COMMENT '4G模块IMEI',
+ auth_str VARCHAR(8) COMMENT '签名安全码',
+ 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;
+
+ 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,
+ 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,
+ end_time TIMESTAMP,
+ energy DECIMAL(10,2) COMMENT '充电量kWh',
+ max_temperature DECIMAL(5,1) COMMENT '最高温度',
+ 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 DECIMAL(10,2) COMMENT '总能耗kWh',
+ 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,
+ FOREIGN KEY (user_id) REFERENCES users(id)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+ "#;
+
+ // 逐条执行,避免多语句执行问题
+ for statement in sql.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) {
+ sqlx::query(statement).execute(pool).await?;
+ }
+
+ // 创建关键索引(IF NOT EXISTS 避免重复)
+ create_indexes(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?;
+
+ 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", "删除设备"),
+ ("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> {
+ // 创建总管理员角色
+ 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?;
+
+ // 为总管理员分配所有权限
+ 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 {
+ // 查询所有权限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?;
+ }
+ }
+
+ // 为企业管理员分配非系统管理权限
+ 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",
+ "user:view", "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 {
+ for code in &org_perm_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?;
+ }
+ }
+ }
+
+ // 为普通用户分配查看和操作权限
+ 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 {
+ for code in &normal_perm_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?;
+ }
+ }
+ }
+
+ tracing::info!("默认角色及权限分配完成");
+ Ok(())
+}
diff --git a/software/api-server/src/db/mod.rs b/software/api-server/src/db/mod.rs
new file mode 100644
index 0000000..b14a9cf
--- /dev/null
+++ b/software/api-server/src/db/mod.rs
@@ -0,0 +1,17 @@
+//! MySQL 数据库连接池
+
+use sqlx::mysql::MySqlPoolOptions;
+use sqlx::MySqlPool;
+
+pub mod migrate;
+
+/// 初始化 MySQL 连接池
+pub async fn init_pool(database_url: &str) -> Result {
+ let pool = MySqlPoolOptions::new()
+ .max_connections(20)
+ .connect(database_url)
+ .await?;
+
+ tracing::info!("MySQL 连接池已建立");
+ Ok(pool)
+}
diff --git a/software/api-server/src/error.rs b/software/api-server/src/error.rs
new file mode 100644
index 0000000..128cc71
--- /dev/null
+++ b/software/api-server/src/error.rs
@@ -0,0 +1,66 @@
+//! 统一错误处理
+
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+use axum::Json;
+use serde_json::json;
+
+/// 应用统一错误类型
+#[derive(thiserror::Error, Debug)]
+#[allow(dead_code)]
+pub enum AppError {
+ #[error("数据库错误: {0}")]
+ Database(#[from] sqlx::Error),
+
+ #[error("Redis错误: {0}")]
+ Redis(#[from] redis::RedisError),
+
+ #[error("未找到: {0}")]
+ NotFound(String),
+
+ #[error("参数错误: {0}")]
+ BadRequest(String),
+
+ #[error("未授权: {0}")]
+ Unauthorized(String),
+
+ #[error("无权限: {0}")]
+ Forbidden(String),
+
+ #[error("请求过于频繁: {0}")]
+ TooManyRequests(String),
+
+ #[error("内部错误: {0}")]
+ Internal(String),
+}
+
+impl IntoResponse for AppError {
+ fn into_response(self) -> Response {
+ let (status, code, message) = match &self {
+ AppError::Database(e) => {
+ tracing::error!("数据库错误: {}", e);
+ (StatusCode::INTERNAL_SERVER_ERROR, "DATABASE_ERROR", "服务器内部错误")
+ }
+ AppError::Redis(e) => {
+ tracing::error!("Redis错误: {}", e);
+ (StatusCode::INTERNAL_SERVER_ERROR, "REDIS_ERROR", "缓存服务错误")
+ }
+ AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "NOT_FOUND", msg.as_str()),
+ AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "BAD_REQUEST", msg.as_str()),
+ AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "UNAUTHORIZED", msg.as_str()),
+ AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "FORBIDDEN", msg.as_str()),
+ AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, "TOO_MANY_REQUESTS", msg.as_str()),
+ AppError::Internal(msg) => {
+ tracing::error!("内部错误: {}", msg);
+ (StatusCode::INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器内部错误")
+ }
+ };
+
+ let body = json!({
+ "code": code,
+ "message": message,
+ });
+
+ (status, Json(body)).into_response()
+ }
+}
diff --git a/software/api-server/src/main.rs b/software/api-server/src/main.rs
new file mode 100644
index 0000000..89d5e11
--- /dev/null
+++ b/software/api-server/src/main.rs
@@ -0,0 +1,97 @@
+//! 充电柜矩阵系统 — API 服务入口
+//!
+//! 提供 HTTP API(后台管理 + H5),处理后端业务逻辑。
+//! TCP 设备通信由独立的 device-server 处理,通过 Redis 队列通信。
+
+mod commands;
+mod config;
+mod db;
+mod error;
+mod middleware;
+mod nodes;
+mod protocol;
+mod redis;
+mod routes;
+mod workers;
+
+use std::net::SocketAddr;
+use tracing_subscriber::EnvFilter;
+
+use commands::{AppState, NodeRegistry, PendingCommands};
+
+#[tokio::main]
+async fn main() {
+ // 初始化日志
+ tracing_subscriber::fmt()
+ .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()))
+ .init();
+
+ // 加载配置
+ let cfg = config::Config::from_env();
+ cfg.validate();
+ tracing::info!("配置加载完成");
+
+ // 初始化 MySQL 连接池
+ let db_pool = db::init_pool(&cfg.database_url)
+ .await
+ .expect("MySQL 连接失败");
+ tracing::info!("数据库连接成功");
+
+ // 执行自动迁移
+ db::migrate::run(&db_pool)
+ .await
+ .expect("数据库迁移失败");
+ tracing::info!("数据库迁移完成");
+
+ // 初始化 Redis 连接
+ let redis_conn = redis::init_connection(&cfg.redis_url)
+ .await
+ .expect("Redis 连接失败");
+ tracing::info!("Redis 连接成功");
+
+ // 初始化共享状态
+ let pending_commands = PendingCommands::new();
+ let nodes = NodeRegistry::new(redis_conn.clone());
+ let state = AppState {
+ redis: redis_conn.clone(),
+ mysql: db_pool.clone(),
+ pending_commands: pending_commands.clone(),
+ nodes: nodes.clone(),
+ };
+
+ // 启动后台协程 — 处理设备上报
+ let report_redis = redis_conn.clone();
+ let report_mysql = db_pool.clone();
+ tokio::spawn(async move {
+ workers::report_worker::start(report_redis, report_mysql).await;
+ });
+
+ // 启动后台协程 — 处理指令响应
+ let reply_redis = redis_conn.clone();
+ tokio::spawn(async move {
+ workers::reply_worker::start(reply_redis, pending_commands).await;
+ });
+
+ // 启动后台协程 — 节点心跳超时检测
+ let checker_nodes = nodes.clone();
+ let heartbeat_timeout = cfg.node_heartbeat_timeout;
+ tokio::spawn(async move {
+ workers::node_checker::run(checker_nodes, heartbeat_timeout).await;
+ });
+
+ // 构建 HTTP 路由
+ let app = routes::build(state);
+
+ // 启动 HTTP 服务
+ let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port)
+ .parse()
+ .expect("无效的监听地址");
+ tracing::info!("HTTP 服务器启动于 http://{}", addr);
+
+ let listener = tokio::net::TcpListener::bind(addr)
+ .await
+ .expect("端口绑定失败");
+ axum::serve(listener, app)
+ .await
+ .expect("服务器运行失败");
+}
diff --git a/software/api-server/src/middleware/auth.rs b/software/api-server/src/middleware/auth.rs
new file mode 100644
index 0000000..0601719
--- /dev/null
+++ b/software/api-server/src/middleware/auth.rs
@@ -0,0 +1,286 @@
+//! 认证与权限校验中间件
+//!
+//! 提供 JWT 令牌解析和 RBAC 权限校验功能:
+//! - `auth_middleware`:从请求头提取 JWT,解析用户信息并注入请求扩展
+//! - `check_permission`:校验用户是否拥有指定权限码
+//! - `CurrentUser`:从请求扩展中提取当前登录用户信息(Axum Extractor)
+//! - `generate_token`:签发 JWT token
+//! - `hash_password` / `verify_password`:密码哈希与验证
+
+use std::collections::HashSet;
+
+use argon2::{
+ password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
+ Argon2,
+};
+use axum::body::Body;
+use axum::extract::{FromRequestParts, State};
+use axum::http::request::Parts;
+use axum::http::Request;
+use axum::middleware::Next;
+use axum::response::{IntoResponse, Response};
+use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
+use serde::{Deserialize, Serialize};
+use sqlx::MySqlPool;
+
+use crate::commands::AppState;
+use crate::config::JWT_SECRET_DEFAULT;
+use crate::error::AppError;
+
+/// JWT 签名密钥环境变量名
+const JWT_SECRET_ENV: &str = "JWT_SECRET";
+
+/// JWT Claims 结构
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Claims {
+ /// 用户ID(subject)
+ pub sub: i64,
+ /// 手机号
+ pub phone: String,
+ /// 过期时间(Unix 时间戳,秒)
+ pub exp: u64,
+ /// 签发时间(Unix 时间戳,秒)
+ pub iat: u64,
+}
+
+/// 当前登录用户信息(从请求扩展中提取)
+#[derive(Debug, Clone)]
+pub struct CurrentUser {
+ /// 用户ID
+ pub user_id: i64,
+ /// 手机号
+ #[allow(dead_code)]
+ pub phone: String,
+ /// 用户拥有的权限码集合
+ pub permissions: HashSet,
+ /// 角色等级:0普通 1企业管理员 2总管理员
+ pub role_level: i32,
+ /// 所属组织ID(总管理员为 None)
+ pub organization_id: Option,
+}
+
+/// 认证中间件 — 从请求头提取 JWT,解析用户信息并注入请求扩展
+///
+/// 配合 `axum::middleware::from_fn_with_state(state, auth_middleware)` 使用
+pub async fn auth_middleware(
+ State(state): State,
+ req: Request,
+ next: Next,
+) -> Response {
+ let pool = &state.mysql;
+ // 从 Authorization 头提取 Bearer token
+ let auth_header = req.headers().get("Authorization")
+ .and_then(|v| v.to_str().ok())
+ .unwrap_or("")
+ .to_string();
+
+ let token = match auth_header.strip_prefix("Bearer ") {
+ Some(t) => t.trim().to_string(),
+ None => {
+ return AppError::Unauthorized("缺少认证令牌".into()).into_response();
+ }
+ };
+
+ // 获取 JWT 密钥
+ let secret = std::env::var(JWT_SECRET_ENV)
+ .unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
+
+ // 解码并验证 JWT
+ let mut validation = Validation::new(Algorithm::HS256);
+ validation.validate_exp = true;
+
+ let token_data = match decode::(
+ &token,
+ &DecodingKey::from_secret(secret.as_bytes()),
+ &validation,
+ ) {
+ Ok(data) => data,
+ Err(_) => {
+ return AppError::Unauthorized("令牌无效或已过期".into()).into_response();
+ }
+ };
+
+ let claims = token_data.claims;
+
+ // 查询用户权限码集合
+ let permissions = match load_user_permissions(pool, claims.sub).await {
+ Ok(perms) => perms,
+ Err(e) => {
+ tracing::error!("查询用户权限失败: {}", e);
+ return AppError::Internal("权限查询失败".into()).into_response();
+ }
+ };
+
+ // 查询用户角色等级和组织ID
+ let (role_level, organization_id) = match load_user_role_and_org(pool, claims.sub).await {
+ Ok(data) => data,
+ Err(e) => {
+ tracing::error!("查询用户角色失败: {}", e);
+ return AppError::Internal("角色查询失败".into()).into_response();
+ }
+ };
+
+ // 构建当前用户信息并注入请求扩展
+ let current_user = CurrentUser {
+ user_id: claims.sub,
+ phone: claims.phone,
+ permissions,
+ role_level,
+ organization_id,
+ };
+
+ // 拆分请求,插入扩展,重新组合
+ let (mut parts, body) = req.into_parts();
+ parts.extensions.insert(current_user);
+ let req = Request::from_parts(parts, body);
+
+ next.run(req).await
+}
+
+/// 从请求中提取当前用户(Axum Extractor 实现)
+impl FromRequestParts for CurrentUser {
+ type Rejection = Response;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ _state: &AppState,
+ ) -> Result {
+ parts.extensions.get::().cloned().ok_or_else(|| {
+ AppError::Unauthorized("未认证".into()).into_response()
+ })
+ }
+}
+
+/// 权限校验 — 检查用户是否拥有指定权限码
+///
+/// 总管理员(role_level >= 2)自动拥有全部权限
+pub fn check_permission(user: &CurrentUser, code: &str) -> Result<(), AppError> {
+ if user.role_level >= 2 {
+ return Ok(());
+ }
+ if user.permissions.contains(code) {
+ Ok(())
+ } else {
+ Err(AppError::Forbidden(format!("缺少权限: {}", code)))
+ }
+}
+
+/// 加载用户权限码集合(公开接口,供路由处理器调用)
+pub async fn load_user_permissions(pool: &MySqlPool, user_id: i64) -> Result, sqlx::Error> {
+ let rows: Vec<(String,)> = sqlx::query_as(
+ r#"
+ SELECT DISTINCT p.code
+ FROM permissions p
+ INNER JOIN role_permissions rp ON rp.permission_id = p.id
+ INNER JOIN user_roles ur ON ur.role_id = rp.role_id
+ WHERE ur.user_id = ?
+ "#,
+ )
+ .bind(user_id)
+ .fetch_all(pool)
+ .await?;
+
+ Ok(rows.into_iter().map(|(code,)| code).collect())
+}
+
+/// 加载用户最高角色等级和组织ID
+async fn load_user_role_and_org(pool: &MySqlPool, user_id: i64) -> Result<(i32, Option), sqlx::Error> {
+ let result: Option<(i32, Option)> = sqlx::query_as(
+ "SELECT COALESCE(MAX(role), 0), organization_id FROM users WHERE id = ?",
+ )
+ .bind(user_id)
+ .fetch_optional(pool)
+ .await?;
+
+ Ok(result.unwrap_or((0, None)))
+}
+
+/// 生成 JWT token(7天有效期)
+pub fn generate_token(user_id: i64, phone: &str) -> Result {
+ let secret = std::env::var(JWT_SECRET_ENV)
+ .unwrap_or_else(|_| JWT_SECRET_DEFAULT.to_string());
+
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_err(|e| AppError::Internal(format!("时间错误: {}", e)))?
+ .as_secs();
+
+ let claims = Claims {
+ sub: user_id,
+ phone: phone.to_string(),
+ exp: now + 86400 * 7,
+ iat: now,
+ };
+
+ encode(
+ &Header::default(),
+ &claims,
+ &EncodingKey::from_secret(secret.as_bytes()),
+ )
+ .map_err(|e| AppError::Internal(format!("Token生成失败: {}", e)))
+}
+
+// ---------------------------------------------------------------------------
+// 密码哈希
+// ---------------------------------------------------------------------------
+
+/// 使用 argon2 对密码进行哈希
+pub fn hash_password(password: &str) -> Result {
+ let salt = SaltString::generate(&mut OsRng);
+ let argon2 = Argon2::default();
+ let hash = argon2
+ .hash_password(password.as_bytes(), &salt)
+ .map_err(|e| AppError::Internal(format!("密码哈希失败: {}", e)))?;
+ Ok(hash.to_string())
+}
+
+/// 验证密码与 argon2 哈希是否匹配
+pub fn verify_password(password: &str, hash: &str) -> Result {
+ let parsed_hash = PasswordHash::new(hash)
+ .map_err(|e| AppError::Internal(format!("密码哈希解析失败: {}", e)))?;
+ let argon2 = Argon2::default();
+ Ok(argon2
+ .verify_password(password.as_bytes(), &parsed_hash)
+ .is_ok())
+}
+
+// ---------------------------------------------------------------------------
+// 组织数据过滤
+// ---------------------------------------------------------------------------
+
+/// 返回角色等级 >= 2 时「总管理员」看全部
+/// 否则按 organization_id 过滤(子查询方式,通过 cabinet → project → org 链路)
+pub fn org_condition(user: &CurrentUser, cabinet_table: &str) -> (String, Vec) {
+ if user.role_level >= 2 {
+ return (String::new(), vec![]);
+ }
+ if let Some(org_id) = user.organization_id {
+ (
+ format!(
+ " AND {}.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)",
+ cabinet_table
+ ),
+ vec![org_id.to_string()],
+ )
+ } else {
+ (" AND 1=0".to_string(), vec![])
+ }
+}
+
+/// 操作日志的组织过滤(通过 users.organization_id 链路)
+pub fn org_condition_for_logs(user: &CurrentUser, log_table: &str) -> (String, Vec) {
+ if user.role_level >= 2 {
+ return (String::new(), vec![]);
+ }
+ if let Some(org_id) = user.organization_id {
+ (
+ format!(
+ " AND {}.user_id IN (SELECT id FROM users WHERE organization_id = ?)",
+ log_table
+ ),
+ vec![org_id.to_string()],
+ )
+ } else {
+ (" AND 1=0".to_string(), vec![])
+ }
+}
diff --git a/software/api-server/src/middleware/mod.rs b/software/api-server/src/middleware/mod.rs
new file mode 100644
index 0000000..4f4346f
--- /dev/null
+++ b/software/api-server/src/middleware/mod.rs
@@ -0,0 +1,3 @@
+//! 中间件模块
+
+pub mod auth;
diff --git a/software/api-server/src/nodes.rs b/software/api-server/src/nodes.rs
new file mode 100644
index 0000000..b50c070
--- /dev/null
+++ b/software/api-server/src/nodes.rs
@@ -0,0 +1,70 @@
+//! 设备服务节点管理 API
+//!
+//! 设备服务启动时注册到 API 服务,定期发送心跳。
+//! API 服务通过节点健康状况判断设备服务是否在线。
+
+use axum::extract::{Path, State};
+use axum::Json;
+use serde::Deserialize;
+use serde_json::{json, Value};
+
+use crate::commands::AppState;
+use crate::error::AppError;
+
+/// 节点注册请求体
+#[derive(Debug, Deserialize)]
+pub struct NodeRegisterRequest {
+ pub node_id: String,
+ pub ip: String,
+ pub tcp_port: u16,
+}
+
+/// POST /api/nodes/register — 设备服务注册
+pub async fn register_node(
+ State(state): State,
+ Json(req): Json,
+) -> Result, AppError> {
+ state.nodes.register(&req.node_id, &req.ip, req.tcp_port).await;
+ tracing::info!("[节点注册] node_id={}", req.node_id);
+ Ok(Json(json!({"suc": 1})))
+}
+
+/// POST /api/nodes/:node_id/heartbeat — 节点心跳
+pub async fn node_heartbeat(
+ State(state): State,
+ Path(node_id): Path,
+) -> Result, AppError> {
+ if state.nodes.heartbeat(&node_id).await {
+ Ok(Json(json!({"suc": 1})))
+ } else {
+ Ok(Json(json!({"suc": 0, "err": "节点未注册"})))
+ }
+}
+
+/// POST /api/nodes/:node_id/deregister — 节点注销
+pub async fn deregister_node(
+ State(state): State,
+ Path(node_id): Path,
+) -> Result, AppError> {
+ state.nodes.remove(&node_id).await;
+ tracing::info!("[节点注销] node_id={}", node_id);
+ Ok(Json(json!({"suc": 1})))
+}
+
+/// GET /api/nodes — 节点列表
+pub async fn list_nodes(
+ State(state): State,
+) -> Result, AppError> {
+ let nodes = state.nodes.list().await;
+ let list: Vec = nodes
+ .into_iter()
+ .map(|n| {
+ json!({
+ "node_id": n.node_id,
+ "ip": n.ip,
+ "tcp_port": n.tcp_port,
+ })
+ })
+ .collect();
+ Ok(Json(json!({"nodes": list, "count": list.len()})))
+}
diff --git a/software/api-server/src/protocol.rs b/software/api-server/src/protocol.rs
new file mode 100644
index 0000000..972adff
--- /dev/null
+++ b/software/api-server/src/protocol.rs
@@ -0,0 +1,61 @@
+//! 设备通讯协议消息类型定义与序列化。
+//!
+//! 设备与平台间采用 JSON 格式通讯,LF (`\n`) 作为消息边界。
+//! 所有 JSON 键名使用蛇形命名(snake_case)。
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+
+/// 设备发往平台的消息。
+///
+/// 字段均为 `Option` 以兼容不同指令所需的参数差异,解析后在 handler 中校验必填字段。
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[allow(dead_code)]
+pub struct DeviceMessage {
+ /// 动作类型:login / auth_str / status_post / pow_fail / bat_in / bat_out / on / off
+ pub act: String,
+ pub dev_id: Option,
+ pub msg_id: Option,
+ /// 登录签名时的时间戳(秒级)
+ pub timestamp: Option,
+ /// SHA256 签名尾巴 7 位
+ pub sign: Option,
+ /// 485 子设备 ID(仓控板 ID 1-6)
+ pub sub_device_id: Option,
+ /// 状态上报时的 HEX 数据
+ pub status: Option,
+ /// 4G 信号强度
+ pub rssi: Option,
+ /// 未在顶层定义的扩展字段
+ #[serde(flatten)]
+ pub extra: Option,
+}
+
+/// 平台回复设备的消息。
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct ServerResponse {
+ /// 1=成功,0=失败
+ pub suc: u8,
+ pub msg_id: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub err: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub auth_str: Option,
+}
+
+impl ServerResponse {
+ /// 创建成功响应。
+ pub fn success(msg_id: Option) -> Self {
+ Self { suc: 1, msg_id, err: None, auth_str: None }
+ }
+
+ /// 创建失败响应。
+ pub fn error(msg_id: Option, err: impl Into) -> Self {
+ Self { suc: 0, msg_id, err: Some(err.into()), auth_str: None }
+ }
+
+ /// 序列化为 JSON 字符串(紧凑格式,无多余空格)。
+ pub fn to_json(&self) -> String {
+ serde_json::to_string(self).expect("ServerResponse 序列化不应失败")
+ }
+}
diff --git a/software/api-server/src/redis.rs b/software/api-server/src/redis.rs
new file mode 100644
index 0000000..300a709
--- /dev/null
+++ b/software/api-server/src/redis.rs
@@ -0,0 +1,12 @@
+//! Redis 连接管理
+
+use redis::aio::ConnectionManager;
+use redis::Client;
+
+/// 建立 Redis 连接管理器
+pub async fn init_connection(redis_url: &str) -> Result {
+ let client = Client::open(redis_url)?;
+ let conn = ConnectionManager::new(client).await?;
+ tracing::info!("Redis 连接已建立");
+ Ok(conn)
+}
diff --git a/software/api-server/src/routes/auth.rs b/software/api-server/src/routes/auth.rs
new file mode 100644
index 0000000..f66ee62
--- /dev/null
+++ b/software/api-server/src/routes/auth.rs
@@ -0,0 +1,118 @@
+//! 认证路由 — 登录/登出/当前用户信息
+//!
+//! - POST /api/auth/login — 手机号+密码登录,返回 JWT token
+//! - POST /api/auth/logout — 登出(客户端清除 token 即可,服务端无状态)
+//! - GET /api/auth/me — 获取当前登录用户信息及权限码
+
+use axum::extract::State;
+use axum::Json;
+use serde::Deserialize;
+use serde_json::{json, Value};
+
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+use crate::commands::AppState;
+
+/// 登录请求体
+#[derive(Debug, Deserialize)]
+pub(crate) struct LoginInput {
+ phone: String,
+ password: String,
+}
+
+/// POST /api/auth/login — 用户登录
+pub async fn login(
+ State(state): State,
+ Json(input): Json,
+) -> Result, AppError> {
+ let pool = &state.mysql;
+
+ if input.phone.is_empty() || input.password.is_empty() {
+ return Err(AppError::BadRequest("手机号和密码不能为空".into()));
+ }
+
+ // 查询用户(含密码比对)
+ let user_row: Option<(i64, Option, String, String, i32, i32)> = sqlx::query_as(
+ "SELECT id, name, phone, password, role, status FROM users WHERE phone = ?"
+ )
+ .bind(&input.phone)
+ .fetch_optional(pool)
+ .await?;
+
+ let (id, name, phone, stored_password, role, status) =
+ user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
+
+ // 检查账户状态
+ if status == 0 {
+ return Err(AppError::Forbidden("账户已被禁用".into()));
+ }
+
+ // 密码校验(argon2 哈希比对)
+ let valid = auth::verify_password(&input.password, &stored_password)
+ .map_err(|_| AppError::Internal("密码验证失败".into()))?;
+ if !valid {
+ return Err(AppError::Unauthorized("手机号或密码错误".into()));
+ }
+
+ // 生成 JWT token
+ let token = auth::generate_token(id, &phone)?;
+
+ // 查询用户权限码
+ let permissions = auth::load_user_permissions(pool, id).await?;
+ let perm_list: Vec = permissions.into_iter().collect();
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "token": token,
+ "user": {
+ "id": id,
+ "name": name,
+ "phone": phone,
+ "role": role,
+ "permissions": perm_list,
+ }
+ },
+ "message": "登录成功"
+ })))
+}
+
+/// POST /api/auth/logout — 用户登出
+pub async fn logout() -> Json {
+ // JWT 无状态,客户端清除 token 即可
+ Json(json!({ "code": 0, "data": null, "message": "登出成功" }))
+}
+
+/// GET /api/auth/me — 获取当前用户信息及权限
+#[allow(clippy::type_complexity)]
+pub async fn me(
+ user: CurrentUser,
+ State(state): State,
+) -> Result, AppError> {
+ let pool = &state.mysql;
+ // 查询用户详细信息
+ let row: Option<(i64, Option, String, i32, Option)> = sqlx::query_as(
+ "SELECT id, name, phone, role, organization_id FROM users WHERE id = ?"
+ )
+ .bind(user.user_id)
+ .fetch_optional(pool)
+ .await?;
+
+ let (id, name, phone, role, org_id) =
+ row.ok_or_else(|| AppError::NotFound("用户不存在".into()))?;
+
+ let perm_list: Vec = user.permissions.into_iter().collect();
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "id": id,
+ "name": name,
+ "phone": phone,
+ "role": role,
+ "organization_id": org_id,
+ "permissions": perm_list,
+ },
+ "message": "ok"
+ })))
+}
diff --git a/software/api-server/src/routes/cabinet_tree.rs b/software/api-server/src/routes/cabinet_tree.rs
new file mode 100644
index 0000000..737d1cf
--- /dev/null
+++ b/software/api-server/src/routes/cabinet_tree.rs
@@ -0,0 +1,113 @@
+//! 组织树 API
+//!
+//! 返回组织->项目->设备完整树状结构。
+//! 根据用户角色进行数据隔离:总管理员看全部,企业管理员只看本组织。
+
+use axum::extract::State;
+use axum::Json;
+use serde_json::{json, Value};
+
+use super::organizations::{CabinetRow, OrganizationRow, ProjectRow, TreeNode};
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+use crate::commands::AppState;
+
+/// GET /api/organization-tree — 返回组织->项目->设备完整树
+///
+/// 需要 `device:view` 权限。
+/// 数据隔离:总管理员看所有组织,企业管理员只看本组织下的数据。
+pub async fn organization_tree(
+ user: CurrentUser,
+ State(state): State,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:view")?;
+
+ let db = &state.mysql;
+
+ // 根据角色过滤组织:总管理员看所有,企业管理员只看本组织
+ let orgs: Vec = if user.role_level >= 2 {
+ sqlx::query_as(
+ r#"SELECT id, name FROM organizations ORDER BY id"#,
+ )
+ .fetch_all(db)
+ .await?
+ } else if let Some(org_id) = user.organization_id {
+ sqlx::query_as(
+ r#"SELECT id, name FROM organizations WHERE id = ? ORDER BY id"#,
+ )
+ .bind(org_id)
+ .fetch_all(db)
+ .await?
+ } else {
+ // 无组织关联的用户看不到任何数据
+ return Ok(Json(json!([])));
+ };
+
+ let projects: Vec = sqlx::query_as(
+ r#"SELECT id, organization_id, name FROM projects ORDER BY id"#,
+ )
+ .fetch_all(db)
+ .await?;
+
+ let cabinets: Vec = sqlx::query_as(
+ r#"SELECT id, project_id, abstract_id, imei, name, status
+ FROM cabinets ORDER BY id"#,
+ )
+ .fetch_all(db)
+ .await?;
+
+ // 构建树
+ let tree: Vec = orgs
+ .into_iter()
+ .map(|org| {
+ let org_projects: Vec = projects
+ .iter()
+ .filter(|p| p.organization_id == Some(org.id))
+ .cloned()
+ .collect();
+
+ let children: Vec = org_projects
+ .into_iter()
+ .map(|proj| {
+ let proj_cabinets: Vec = cabinets
+ .iter()
+ .filter(|c| c.project_id == Some(proj.id))
+ .cloned()
+ .collect();
+
+ let cab_nodes: Vec = proj_cabinets
+ .into_iter()
+ .map(|c| TreeNode {
+ id: c.id,
+ name: c.name.unwrap_or_else(|| c.abstract_id.clone()),
+ children: None,
+ abstract_id: Some(c.abstract_id),
+ imei: Some(c.imei),
+ status: Some(c.status),
+ })
+ .collect();
+
+ TreeNode {
+ id: proj.id,
+ name: proj.name,
+ children: Some(cab_nodes),
+ abstract_id: None,
+ imei: None,
+ status: None,
+ }
+ })
+ .collect();
+
+ TreeNode {
+ id: org.id,
+ name: org.name,
+ children: Some(children),
+ abstract_id: None,
+ imei: None,
+ status: None,
+ }
+ })
+ .collect();
+
+ Ok(Json(json!(tree)))
+}
diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs
new file mode 100644
index 0000000..ba7439a
--- /dev/null
+++ b/software/api-server/src/routes/cabinets.rs
@@ -0,0 +1,425 @@
+//! 设备管理 API — CRUD + 详情 + 级联下拉
+//!
+//! 提供柜子的增删改查、详情查询、安全码重新生成,
+//! 以及柜子/仓板/仓体的级联下拉选项。
+
+use axum::extract::{Path, Query, State};
+use axum::Json;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+use sqlx::MySqlPool;
+
+use super::organizations::{
+ generate_abstract_id, generate_auth_str,
+ CabinetDetail, CabinetRow, CabinBoardRow, CabinBoardWithCompartments,
+ CompartmentRow, CreateCabinetBody, UpdateCabinetBody,
+};
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+
+// ---------------------------------------------------------------------------
+// 项目访问权限验证
+// ---------------------------------------------------------------------------
+
+/// 验证用户是否有权访问指定项目
+///
+/// 总管理员(role_level >= 2)可访问所有项目;
+/// 企业管理员只能访问本组织下的项目。
+async fn check_project_access(
+ user: &CurrentUser,
+ pool: &MySqlPool,
+ project_id: i64,
+) -> Result<(), AppError> {
+ // 总管理员可访问所有项目
+ if user.role_level >= 2 {
+ return Ok(());
+ }
+
+ if let Some(org_id) = user.organization_id {
+ // 验证项目是否属于用户的组织
+ let project: Option<(i64,)> = sqlx::query_as(
+ "SELECT id FROM projects WHERE id = ? AND organization_id = ?",
+ )
+ .bind(project_id)
+ .bind(org_id)
+ .fetch_optional(pool)
+ .await?;
+
+ if project.is_none() {
+ return Err(AppError::Forbidden("无权访问该项目".into()));
+ }
+ } else {
+ return Err(AppError::Forbidden("无组织关联".into()));
+ }
+
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// 设备 CRUD
+// ---------------------------------------------------------------------------
+
+/// GET /api/projects/:project_id/cabinets — 按项目查询柜子列表
+pub async fn list_cabinets(
+ user: CurrentUser,
+ State(state): State,
+ Path(project_id): Path,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:view")?;
+ check_project_access(&user, &state.mysql, project_id).await?;
+
+ let db = &state.mysql;
+ let rows = sqlx::query_as::<_, CabinetRow>(
+ r#"SELECT id, project_id, abstract_id, imei, name, status
+ FROM cabinets WHERE project_id = ? ORDER BY id"#,
+ )
+ .bind(project_id)
+ .fetch_all(db)
+ .await?;
+
+ Ok(Json(json!(rows)))
+}
+
+/// POST /api/cabinets — 批量添加柜子
+///
+/// 每个 IMEI 创建一个柜子,同时生成默认仓控板和 6 个仓体。
+pub async fn create_cabinet(
+ user: CurrentUser,
+ State(state): State,
+ Json(body): Json,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:create")?;
+
+ let db = &state.mysql;
+ if body.imeis.is_empty() {
+ return Err(AppError::BadRequest("IMEI 列表不能为空".into()));
+ }
+
+ let mut unique_imeis: Vec = Vec::new();
+ for imei in &body.imeis {
+ let trimmed = imei.trim().to_string();
+ if trimmed.len() != 15 {
+ return Err(AppError::BadRequest(
+ format!("IMEI 必须为15位数字: {}", trimmed),
+ ));
+ }
+ if !unique_imeis.contains(&trimmed) {
+ unique_imeis.push(trimmed);
+ }
+ }
+
+ let mut created_ids: Vec = Vec::new();
+
+ for imei in &unique_imeis {
+ let abstract_id = generate_abstract_id(imei);
+ let auth_str = generate_auth_str();
+
+ let mut tx = db.begin().await.map_err(AppError::Database)?;
+
+ let result = sqlx::query(
+ r#"INSERT INTO cabinets (project_id, abstract_id, imei, auth_str)
+ VALUES (?, ?, ?, ?)"#,
+ )
+ .bind(body.project_id)
+ .bind(&abstract_id)
+ .bind(imei)
+ .bind(&auth_str)
+ .execute(&mut *tx)
+ .await?;
+
+ let cabinet_id = result.last_insert_id() as i64;
+
+ let board_result = sqlx::query(
+ r#"INSERT INTO cabin_boards (cabinet_id, board_id) VALUES (?, 1)"#,
+ )
+ .bind(cabinet_id)
+ .execute(&mut *tx)
+ .await?;
+
+ let board_pk = board_result.last_insert_id() as i64;
+
+ for ch in 1..=6 {
+ sqlx::query(
+ r#"INSERT INTO compartments (cabin_board_id, channel_id) VALUES (?, ?)"#,
+ )
+ .bind(board_pk)
+ .bind(ch)
+ .execute(&mut *tx)
+ .await?;
+ }
+
+ tx.commit().await.map_err(AppError::Database)?;
+ created_ids.push(cabinet_id);
+ tracing::info!("创建柜子 id={} abstract_id={} imei={}", cabinet_id, abstract_id, imei);
+ }
+
+ Ok(Json(json!({ "ids": created_ids })))
+}
+
+/// PUT /api/cabinets/:id — 更新柜子(划拨项目等)
+pub async fn update_cabinet(
+ user: CurrentUser,
+ State(state): State,
+ Path(id): Path,
+ Json(body): Json,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:edit")?;
+
+ let db = &state.mysql;
+ let rows = sqlx::query(
+ r#"UPDATE cabinets SET
+ project_id = COALESCE(?, project_id),
+ name = COALESCE(?, name)
+ WHERE id = ?"#,
+ )
+ .bind(body.project_id)
+ .bind(&body.name)
+ .bind(id)
+ .execute(db)
+ .await?
+ .rows_affected();
+
+ if rows == 0 {
+ return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
+ }
+
+ tracing::info!("更新柜子 id={}", id);
+ Ok(Json(json!({})))
+}
+
+/// DELETE /api/cabinets/:id — 删除柜子(级联删除仓控板和仓体)
+pub async fn delete_cabinet(
+ user: CurrentUser,
+ State(state): State,
+ Path(id): Path,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:delete")?;
+
+ let db = &state.mysql;
+ let mut tx = db.begin().await.map_err(AppError::Database)?;
+
+ sqlx::query(
+ r#"DELETE compartments FROM compartments
+ INNER JOIN cabin_boards ON compartments.cabin_board_id = cabin_boards.id
+ WHERE cabin_boards.cabinet_id = ?"#,
+ )
+ .bind(id)
+ .execute(&mut *tx)
+ .await?;
+
+ sqlx::query(r#"DELETE FROM cabin_boards WHERE cabinet_id = ?"#)
+ .bind(id)
+ .execute(&mut *tx)
+ .await?;
+
+ let rows = sqlx::query(r#"DELETE FROM cabinets WHERE id = ?"#)
+ .bind(id)
+ .execute(&mut *tx)
+ .await?
+ .rows_affected();
+
+ tx.commit().await.map_err(AppError::Database)?;
+
+ if rows == 0 {
+ return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
+ }
+
+ tracing::info!("删除柜子 id={}", id);
+ Ok(Json(json!({})))
+}
+
+/// POST /api/cabinets/:id/regenerate-auth — 重新生成安全码
+pub async fn regenerate_auth(
+ user: CurrentUser,
+ State(state): State,
+ Path(id): Path,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:edit")?;
+
+ let db = &state.mysql;
+ let auth_str = generate_auth_str();
+
+ let rows = sqlx::query(r#"UPDATE cabinets SET auth_str = ? WHERE id = ?"#)
+ .bind(&auth_str)
+ .bind(id)
+ .execute(db)
+ .await?
+ .rows_affected();
+
+ if rows == 0 {
+ return Err(AppError::NotFound(format!("柜子 {} 不存在", id)));
+ }
+
+ tracing::info!("重新生成 auth_str id={}", id);
+ Ok(Json(json!({ "auth_str": auth_str })))
+}
+
+// ---------------------------------------------------------------------------
+// 柜子详情(含仓控板 + 仓体)
+// ---------------------------------------------------------------------------
+
+/// GET /api/cabinets/:id — 柜子详情,包含仓控板和仓体
+pub async fn get_cabinet_detail(
+ user: CurrentUser,
+ State(state): State,
+ Path(id): Path,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:view")?;
+
+ let db = &state.mysql;
+ let cabinet = sqlx::query_as::<_, CabinetRow>(
+ r#"SELECT id, project_id, abstract_id, imei, name, status
+ FROM cabinets WHERE id = ?"#,
+ )
+ .bind(id)
+ .fetch_optional(db)
+ .await?
+ .ok_or_else(|| AppError::NotFound(format!("柜子 {} 不存在", id)))?;
+
+ // 验证用户是否有权访问该柜子所属的项目
+ if let Some(pid) = cabinet.project_id {
+ check_project_access(&user, db, pid).await?;
+ }
+
+ let boards: Vec = sqlx::query_as(
+ r#"SELECT id, cabinet_id, board_id, status
+ FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"#,
+ )
+ .bind(id)
+ .fetch_all(db)
+ .await?;
+
+ let mut cabin_boards = Vec::new();
+ for board in boards {
+ let compartments: Vec = sqlx::query_as(
+ r#"SELECT id, cabin_board_id, channel_id, status
+ FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"#,
+ )
+ .bind(board.id)
+ .fetch_all(db)
+ .await?;
+
+ cabin_boards.push(CabinBoardWithCompartments {
+ id: board.id,
+ board_id: board.board_id,
+ status: board.status,
+ compartments,
+ });
+ }
+
+ Ok(Json(CabinetDetail {
+ id: cabinet.id,
+ project_id: cabinet.project_id,
+ abstract_id: cabinet.abstract_id,
+ imei: cabinet.imei,
+ name: cabinet.name,
+ status: cabinet.status,
+ cabin_boards,
+ }))
+}
+
+// ---------------------------------------------------------------------------
+// 级联下拉选项
+// ---------------------------------------------------------------------------
+
+/// 柜子下拉查询参数
+#[derive(Debug, Deserialize)]
+pub struct CabinetListQuery {
+ pub project_id: Option,
+}
+
+/// 仓板下拉查询参数
+#[derive(Debug, Deserialize)]
+pub struct CabinBoardListQuery {
+ pub cabinet_id: i64,
+}
+
+/// 仓体下拉查询参数
+#[derive(Debug, Deserialize)]
+pub struct CompartmentListQuery {
+ pub cabin_board_id: i64,
+}
+
+/// 柜子下拉选项
+#[derive(Debug, Serialize, sqlx::FromRow)]
+pub struct CabinetOption {
+ pub id: i64,
+ pub name: Option,
+ pub abstract_id: String,
+}
+
+/// 仓板下拉选项
+#[derive(Debug, Serialize, sqlx::FromRow)]
+pub struct CabinBoardOption {
+ pub id: i64,
+ pub board_id: i8,
+}
+
+/// 仓体下拉选项
+#[derive(Debug, Serialize, sqlx::FromRow)]
+pub struct CompartmentOption {
+ pub id: i64,
+ pub channel_id: i8,
+}
+
+/// GET /api/cabinets/options — 柜子下拉列表
+pub async fn list_cabinet_options(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:view")?;
+
+ let rows = if let Some(pid) = params.project_id {
+ sqlx::query_as::<_, CabinetOption>(
+ "SELECT id, name, abstract_id FROM cabinets WHERE project_id = ? ORDER BY id"
+ )
+ .bind(pid)
+ .fetch_all(&state.mysql)
+ .await?
+ } else {
+ sqlx::query_as::<_, CabinetOption>(
+ "SELECT id, name, abstract_id FROM cabinets ORDER BY id"
+ )
+ .fetch_all(&state.mysql)
+ .await?
+ };
+
+ Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
+}
+
+/// GET /api/cabin-boards/options — 仓板下拉列表
+pub async fn list_cabin_board_options(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:view")?;
+
+ let rows = sqlx::query_as::<_, CabinBoardOption>(
+ "SELECT id, board_id FROM cabin_boards WHERE cabinet_id = ? ORDER BY board_id"
+ )
+ .bind(params.cabinet_id)
+ .fetch_all(&state.mysql)
+ .await?;
+
+ Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
+}
+
+/// GET /api/compartments/options — 仓体下拉列表
+pub async fn list_compartment_options(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device:view")?;
+
+ let rows = sqlx::query_as::<_, CompartmentOption>(
+ "SELECT id, channel_id FROM compartments WHERE cabin_board_id = ? ORDER BY channel_id"
+ )
+ .bind(params.cabin_board_id)
+ .fetch_all(&state.mysql)
+ .await?;
+
+ Ok(Json(json!({ "code": 0, "data": rows, "message": "ok" })))
+}
diff --git a/software/api-server/src/routes/charge_records.rs b/software/api-server/src/routes/charge_records.rs
new file mode 100644
index 0000000..fae117e
--- /dev/null
+++ b/software/api-server/src/routes/charge_records.rs
@@ -0,0 +1,397 @@
+//! 充电记录 API — 列表查询 + 异步导出
+
+use axum::extract::{Query, State};
+use axum::Json;
+use chrono::NaiveDateTime;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+
+/// 充电记录列表查询参数
+#[derive(Debug, Deserialize)]
+pub struct ChargeRecordQuery {
+ pub cabinet_id: Option,
+ pub cabin_board_id: Option,
+ pub compartment_id: Option,
+ pub start_time: Option,
+ pub end_time: Option,
+ #[serde(default = "default_page")]
+ pub page: i64,
+ #[serde(default = "default_page_size")]
+ pub page_size: i64,
+}
+
+/// 充电记录导出查询参数
+#[derive(Debug, Deserialize)]
+pub struct ChargeRecordExportQuery {
+ pub cabinet_id: Option,
+ pub start_time: Option,
+ pub end_time: Option,
+}
+
+/// 充电记录列表项
+#[derive(Debug, Serialize)]
+pub struct ChargeRecordItem {
+ pub id: i64,
+ pub cabinet_id: i64,
+ pub cabinet_name: Option,
+ pub cabin_board_id: i64,
+ pub board_id: i8,
+ pub compartment_id: i64,
+ pub channel_id: i8,
+ pub start_time: Option,
+ pub end_time: Option,
+ pub energy: Option,
+ pub max_temperature: Option,
+ pub status: i8,
+ pub status_text: String,
+}
+
+fn default_page() -> i64 { 1 }
+fn default_page_size() -> i64 { 20 }
+
+/// 状态码转中文描述
+fn status_text(status: i8) -> &'static str {
+ match status {
+ 0 => "正常",
+ 1 => "异常",
+ 2 => "手动停止",
+ _ => "未知",
+ }
+}
+
+/// 数据库行映射 — DECIMAL 列通过 CAST 转为 f64
+#[derive(Debug, sqlx::FromRow)]
+struct ChargeRecordRow {
+ id: i64,
+ cabinet_id: i64,
+ cabinet_name: Option,
+ compartment_id: i64,
+ cabin_board_id: Option,
+ board_id: Option,
+ channel_id: Option,
+ start_time: Option,
+ end_time: Option,
+ energy: Option,
+ max_temperature: Option,
+ status: i8,
+}
+
+/// 构建 WHERE 条件子句及绑定值
+fn build_where_clause(
+ cabinet_id: Option,
+ cabin_board_id: Option,
+ compartment_id: Option,
+ start_time: Option<&str>,
+ end_time: Option<&str>,
+) -> (String, Vec) {
+ let mut conditions = Vec::new();
+ let mut bind_values = Vec::new();
+
+ if let Some(id) = cabinet_id {
+ conditions.push("cr.cabinet_id = ?");
+ bind_values.push(id.to_string());
+ }
+ if let Some(id) = cabin_board_id {
+ conditions.push("cb.id = ?");
+ bind_values.push(id.to_string());
+ }
+ if let Some(id) = compartment_id {
+ conditions.push("cr.compartment_id = ?");
+ bind_values.push(id.to_string());
+ }
+ if let Some(t) = start_time {
+ conditions.push("cr.start_time >= ?");
+ bind_values.push(t.to_string());
+ }
+ if let Some(t) = end_time {
+ conditions.push("cr.start_time <= ?");
+ bind_values.push(t.to_string());
+ }
+
+ let clause = if conditions.is_empty() {
+ String::new()
+ } else {
+ format!("WHERE {}", conditions.join(" AND "))
+ };
+ (clause, bind_values)
+}
+
+/// 查询充电记录行(复用 SQL 模板)
+async fn fetch_charge_rows(
+ pool: &sqlx::MySqlPool,
+ where_clause: &str,
+ bind_values: &[String],
+ limit: Option,
+ offset: Option,
+) -> Result, sqlx::Error> {
+ let limit_clause = match (limit, offset) {
+ (Some(l), Some(o)) => format!("LIMIT {} OFFSET {}", l, o),
+ (Some(l), None) => format!("LIMIT {}", l),
+ _ => String::new(),
+ };
+
+ let sql = format!(
+ "SELECT cr.id, cr.cabinet_id, c.name AS cabinet_name, \
+ cr.compartment_id, cb.id AS cabin_board_id, cb.board_id, comp.channel_id, \
+ cr.start_time, cr.end_time, \
+ CAST(cr.energy AS DOUBLE) AS energy, \
+ CAST(cr.max_temperature AS DOUBLE) AS max_temperature, \
+ cr.status \
+ FROM charge_records cr \
+ LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
+ LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
+ LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
+ {} ORDER BY cr.start_time DESC {}",
+ where_clause, limit_clause
+ );
+
+ let mut query = sqlx::query_as::<_, ChargeRecordRow>(&sql);
+ for val in bind_values {
+ query = query.bind(val);
+ }
+ query.fetch_all(pool).await
+}
+
+/// GET /api/charge-records — 充电记录分页列表
+pub async fn list_charge_records(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "charge_record:view")?;
+ let offset = (params.page - 1) * params.page_size;
+
+ let (mut where_clause, mut bind_values) = build_where_clause(
+ params.cabinet_id,
+ params.cabin_board_id,
+ params.compartment_id,
+ params.start_time.as_deref(),
+ params.end_time.as_deref(),
+ );
+
+ // 追加组织数据隔离
+ let (org_cond, org_binds) = auth::org_condition(&user, "cr");
+ if !org_cond.is_empty() {
+ if where_clause.is_empty() {
+ where_clause = format!("WHERE {}", org_cond.trim_start_matches(" AND "));
+ } else {
+ where_clause.push_str(&org_cond);
+ }
+ bind_values.extend(org_binds);
+ }
+
+ // 查询总数(需要与数据查询保持一致的 JOIN 链路)
+ let count_sql = format!(
+ "SELECT COUNT(*) FROM charge_records cr \
+ LEFT JOIN cabinets c ON cr.cabinet_id = c.id \
+ LEFT JOIN compartments comp ON cr.compartment_id = comp.id \
+ LEFT JOIN cabin_boards cb ON comp.cabin_board_id = cb.id \
+ {}",
+ where_clause
+ );
+ let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
+ for val in &bind_values {
+ count_query = count_query.bind(val);
+ }
+ let total = count_query.fetch_one(&state.mysql).await?;
+
+ // 查询列表
+ let rows = fetch_charge_rows(
+ &state.mysql,
+ &where_clause,
+ &bind_values,
+ Some(params.page_size),
+ Some(offset),
+ )
+ .await?;
+
+ let list: Vec = rows
+ .into_iter()
+ .map(|r| ChargeRecordItem {
+ id: r.id,
+ cabinet_id: r.cabinet_id,
+ cabinet_name: r.cabinet_name,
+ cabin_board_id: r.cabin_board_id.unwrap_or(0),
+ board_id: r.board_id.unwrap_or(0),
+ compartment_id: r.compartment_id,
+ channel_id: r.channel_id.unwrap_or(0),
+ start_time: r.start_time.map(|t| t.to_string()),
+ end_time: r.end_time.map(|t| t.to_string()),
+ energy: r.energy,
+ max_temperature: r.max_temperature,
+ status: r.status,
+ status_text: status_text(r.status).to_string(),
+ })
+ .collect();
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "list": list,
+ "total": total,
+ "page": params.page,
+ "page_size": params.page_size,
+ },
+ "message": "ok"
+ })))
+}
+
+/// GET /api/charge-records/export — 创建异步导出任务
+pub async fn export_charge_records(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "charge_record:export")?;
+ let user_id = user.user_id;
+ let file_name = generate_export_filename(¶ms);
+
+ // 序列化查询参数
+ let query_json = serde_json::to_string(&json!({
+ "cabinet_id": params.cabinet_id,
+ "start_time": params.start_time,
+ "end_time": params.end_time,
+ }))
+ .unwrap_or_default();
+
+ // 插入下载任务(error_message 临时传递查询参数)
+ let result = sqlx::query(
+ "INSERT INTO download_tasks (user_id, task_type, status, file_name, error_message) \
+ VALUES (?, ?, 0, ?, ?)"
+ )
+ .bind(user_id)
+ .bind("charge_records")
+ .bind(&file_name)
+ .bind(&query_json)
+ .execute(&state.mysql)
+ .await?;
+
+ let task_id = result.last_insert_id() as i64;
+
+ // 启动后台异步生成 Excel
+ let pool = state.mysql.clone();
+ tokio::spawn(async move {
+ if let Err(e) = generate_charge_records_excel(pool, task_id).await {
+ tracing::error!("导出充电记录失败 task_id={}: {}", task_id, e);
+ }
+ });
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": { "task_id": task_id },
+ "message": "导出任务已创建"
+ })))
+}
+
+/// 生成导出文件名:充电记录_柜名_时间范围.xlsx
+fn generate_export_filename(params: &ChargeRecordExportQuery) -> String {
+ let start = params
+ .start_time
+ .as_ref()
+ .map(|s| s.replace(['-', ':', ' '], ""))
+ .unwrap_or_default();
+ let end = params
+ .end_time
+ .as_ref()
+ .map(|s| s.replace(['-', ':', ' '], ""))
+ .unwrap_or_default();
+ let cabinet_part = params
+ .cabinet_id
+ .map(|id| format!("柜{}", id))
+ .unwrap_or_default();
+ format!("充电记录_{}_{}-{}.xlsx", cabinet_part, start, end)
+}
+
+/// 后台异步生成 Excel 文件
+async fn generate_charge_records_excel(
+ pool: sqlx::MySqlPool,
+ task_id: i64,
+) -> Result<(), Box> {
+ // 更新状态为处理中
+ sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
+ .bind(task_id)
+ .execute(&pool)
+ .await?;
+
+ // 获取查询参数
+ let row: Option<(String, String)> = sqlx::query_as(
+ "SELECT file_name, error_message FROM download_tasks WHERE id = ?"
+ )
+ .bind(task_id)
+ .fetch_optional(&pool)
+ .await?;
+
+ let (file_name, query_json) = row.ok_or("任务不存在")?;
+
+ let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
+
+ let (where_clause, bind_values) = build_where_clause(
+ params.get("cabinet_id").and_then(|v| v.as_i64()),
+ None,
+ None,
+ params.get("start_time").and_then(|v| v.as_str()),
+ params.get("end_time").and_then(|v| v.as_str()),
+ );
+
+ let rows = fetch_charge_rows(&pool, &where_clause, &bind_values, None, None).await?;
+
+ // 更新进度 50%
+ sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
+ .bind(task_id)
+ .execute(&pool)
+ .await?;
+
+ // 生成 Excel
+ let mut workbook = rust_xlsxwriter::Workbook::new();
+ let sheet = workbook.add_worksheet();
+ sheet.set_name("充电记录")?;
+
+ let headers = [
+ "ID", "柜子", "仓板", "通道", "开始时间", "结束时间",
+ "充电量(kWh)", "最高温度(°C)", "状态",
+ ];
+ for (col, header) in headers.iter().enumerate() {
+ sheet.write_string(0, col as u16, *header)?;
+ }
+
+ for (row_idx, record) in rows.iter().enumerate() {
+ let row = (row_idx + 1) as u32;
+ sheet.write_number(row, 0, record.id as f64)?;
+ sheet.write_string(row, 1, record.cabinet_name.as_deref().unwrap_or("-"))?;
+ sheet.write_number(row, 2, record.board_id.unwrap_or(0) as f64)?;
+ sheet.write_number(row, 3, record.channel_id.unwrap_or(0) as f64)?;
+ sheet.write_string(
+ row, 4,
+ record.start_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
+ )?;
+ sheet.write_string(
+ row, 5,
+ record.end_time.map(|t| t.to_string()).as_deref().unwrap_or("-"),
+ )?;
+ sheet.write_number(row, 6, record.energy.unwrap_or(0.0))?;
+ sheet.write_number(row, 7, record.max_temperature.unwrap_or(0.0))?;
+ sheet.write_string(row, 8, status_text(record.status))?;
+ }
+
+ // 保存文件
+ let file_dir = "./exports";
+ tokio::fs::create_dir_all(file_dir).await?;
+ let file_path = format!("{}/{}", file_dir, file_name);
+ workbook.save(&file_path)?;
+
+ // 更新任务状态为完成
+ sqlx::query(
+ "UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
+ error_message = NULL, completed_at = NOW() WHERE id = ?"
+ )
+ .bind(&file_path)
+ .bind(task_id)
+ .execute(&pool)
+ .await?;
+
+ tracing::info!("充电记录导出完成 task_id={} file={}", task_id, file_path);
+ Ok(())
+}
diff --git a/software/api-server/src/routes/device_logs.rs b/software/api-server/src/routes/device_logs.rs
new file mode 100644
index 0000000..7ed8184
--- /dev/null
+++ b/software/api-server/src/routes/device_logs.rs
@@ -0,0 +1,157 @@
+//! 设备日志 API — 分页列表查询
+
+use axum::extract::{Query, State};
+use axum::Json;
+use chrono::NaiveDateTime;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+
+/// 设备日志查询参数
+#[derive(Debug, Deserialize)]
+pub struct DeviceLogQuery {
+ pub cabinet_id: Option,
+ pub log_type: Option,
+ pub start_time: Option,
+ pub end_time: Option,
+ #[serde(default = "default_page")]
+ pub page: i64,
+ #[serde(default = "default_page_size")]
+ pub page_size: i64,
+}
+
+/// 设备日志列表项
+#[derive(Debug, Serialize)]
+pub struct DeviceLogItem {
+ pub id: i64,
+ pub cabinet_id: i64,
+ pub cabinet_name: Option,
+ pub log_type: i8,
+ pub log_type_text: String,
+ pub content: Option,
+ pub created_at: Option,
+}
+
+fn default_page() -> i64 { 1 }
+fn default_page_size() -> i64 { 20 }
+
+/// 日志类型码转中文
+fn log_type_text(log_type: i8) -> &'static str {
+ match log_type {
+ 1 => "状态上报",
+ 2 => "故障",
+ 3 => "告警",
+ 4 => "停电",
+ _ => "未知",
+ }
+}
+
+/// 数据库行映射
+#[derive(Debug, sqlx::FromRow)]
+struct DeviceLogRow {
+ id: i64,
+ cabinet_id: i64,
+ cabinet_name: Option,
+ log_type: i8,
+ content: Option,
+ created_at: Option,
+}
+
+/// GET /api/device-logs — 设备日志分页列表
+pub async fn list_device_logs(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "device_log:view")?;
+ let offset = (params.page - 1) * params.page_size;
+
+ // 构建 WHERE 条件
+ let mut conditions = Vec::new();
+ let mut bind_values: Vec = Vec::new();
+
+ if let Some(cabinet_id) = params.cabinet_id {
+ conditions.push("dl.cabinet_id = ?");
+ bind_values.push(cabinet_id.to_string());
+ }
+ if let Some(log_type) = params.log_type {
+ conditions.push("dl.log_type = ?");
+ bind_values.push(log_type.to_string());
+ }
+ if let Some(ref start_time) = params.start_time {
+ conditions.push("dl.created_at >= ?");
+ bind_values.push(start_time.clone());
+ }
+ if let Some(ref end_time) = params.end_time {
+ conditions.push("dl.created_at <= ?");
+ bind_values.push(end_time.clone());
+ }
+
+ // 追加组织数据隔离
+ let (org_cond, org_binds) = auth::org_condition(&user, "dl");
+ if !org_cond.is_empty() {
+ conditions.push(org_cond.trim_start_matches(" AND "));
+ bind_values.extend(org_binds);
+ }
+
+ let where_clause = if conditions.is_empty() {
+ String::new()
+ } else {
+ format!("WHERE {}", conditions.join(" AND "))
+ };
+
+ // 查询总数
+ let count_sql = format!(
+ "SELECT COUNT(*) FROM device_logs dl {}",
+ where_clause
+ );
+ let mut count_query = sqlx::query_scalar::<_, i64>(&count_sql);
+ for val in &bind_values {
+ count_query = count_query.bind(val);
+ }
+ let total = count_query.fetch_one(&state.mysql).await?;
+
+ // 查询列表
+ let list_sql = format!(
+ "SELECT dl.id, dl.cabinet_id, c.name AS cabinet_name, \
+ dl.log_type, dl.content, dl.created_at \
+ FROM device_logs dl \
+ LEFT JOIN cabinets c ON dl.cabinet_id = c.id \
+ {} ORDER BY dl.created_at DESC LIMIT ? OFFSET ?",
+ where_clause
+ );
+
+ let mut list_query = sqlx::query_as::<_, DeviceLogRow>(&list_sql);
+ for val in &bind_values {
+ list_query = list_query.bind(val);
+ }
+ list_query = list_query.bind(params.page_size).bind(offset);
+
+ let rows = list_query.fetch_all(&state.mysql).await?;
+
+ let list: Vec = rows
+ .into_iter()
+ .map(|r| DeviceLogItem {
+ id: r.id,
+ cabinet_id: r.cabinet_id,
+ cabinet_name: r.cabinet_name,
+ log_type: r.log_type,
+ log_type_text: log_type_text(r.log_type).to_string(),
+ content: r.content,
+ created_at: r.created_at.map(|t| t.to_string()),
+ })
+ .collect();
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "list": list,
+ "total": total,
+ "page": params.page,
+ "page_size": params.page_size,
+ },
+ "message": "ok"
+ })))
+}
diff --git a/software/api-server/src/routes/downloads.rs b/software/api-server/src/routes/downloads.rs
new file mode 100644
index 0000000..27c054e
--- /dev/null
+++ b/software/api-server/src/routes/downloads.rs
@@ -0,0 +1,280 @@
+//! 下载中心 API — 任务列表、下载链接、文件下载、过期清理
+
+use axum::body::Body;
+use axum::extract::{Path, Query, State};
+use axum::http::header;
+use axum::response::Response;
+use axum::Json;
+use chrono::NaiveDateTime;
+use serde::{Deserialize, Serialize};
+use serde_json::{json, Value};
+
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+
+/// 下载任务列表查询参数
+#[derive(Debug, Deserialize)]
+pub struct DownloadListQuery {
+ #[serde(default = "default_page")]
+ pub page: i64,
+ #[serde(default = "default_page_size")]
+ pub page_size: i64,
+}
+
+/// 清理请求参数
+#[derive(Debug, Deserialize)]
+pub struct CleanupRequest {
+ /// 保留天数,默认7天
+ #[serde(default = "default_retain_days")]
+ pub retain_days: i32,
+}
+
+/// 下载任务列表项
+#[derive(Debug, Serialize)]
+pub struct DownloadTaskItem {
+ pub id: i64,
+ pub task_type: String,
+ pub status: i8,
+ pub status_text: String,
+ pub file_name: Option,
+ pub progress: i32,
+ pub error_message: Option,
+ pub created_at: Option,
+ pub completed_at: Option,
+}
+
+fn default_page() -> i64 { 1 }
+fn default_page_size() -> i64 { 20 }
+fn default_retain_days() -> i32 { 7 }
+
+/// 状态码转文本
+fn download_status_text(status: i8) -> &'static str {
+ match status {
+ 0 => "排队中",
+ 1 => "处理中",
+ 2 => "已完成",
+ 3 => "失败",
+ _ => "未知",
+ }
+}
+
+/// 数据库行映射
+#[derive(Debug, sqlx::FromRow)]
+struct DownloadTaskRow {
+ id: i64,
+ task_type: String,
+ status: i8,
+ file_name: Option,
+ file_path: Option,
+ progress: i32,
+ error_message: Option,
+ created_at: Option,
+ completed_at: Option,
+}
+
+/// 将数据库行转为响应对象
+fn row_to_item(row: DownloadTaskRow) -> DownloadTaskItem {
+ DownloadTaskItem {
+ id: row.id,
+ task_type: row.task_type,
+ status: row.status,
+ status_text: download_status_text(row.status).to_string(),
+ file_name: row.file_name,
+ progress: row.progress,
+ error_message: if row.status == 3 { row.error_message } else { None },
+ created_at: row.created_at.map(|t| t.to_string()),
+ completed_at: row.completed_at.map(|t| t.to_string()),
+ }
+}
+
+/// GET /api/downloads — 下载任务列表
+pub async fn list_downloads(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "charge_record:export")?;
+ let user_id = user.user_id;
+ let offset = (params.page - 1) * params.page_size;
+
+ // 查询总数
+ let total: i64 = sqlx::query_scalar(
+ "SELECT COUNT(*) FROM download_tasks WHERE user_id = ?"
+ )
+ .bind(user_id)
+ .fetch_one(&state.mysql)
+ .await?;
+
+ // 查询列表
+ let rows = sqlx::query_as::<_, DownloadTaskRow>(
+ "SELECT id, task_type, status, file_name, file_path, progress, \
+ error_message, created_at, completed_at \
+ FROM download_tasks WHERE user_id = ? \
+ ORDER BY created_at DESC LIMIT ? OFFSET ?"
+ )
+ .bind(user_id)
+ .bind(params.page_size)
+ .bind(offset)
+ .fetch_all(&state.mysql)
+ .await?;
+
+ let list: Vec = rows.into_iter().map(row_to_item).collect();
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "list": list,
+ "total": total,
+ "page": params.page,
+ "page_size": params.page_size,
+ },
+ "message": "ok"
+ })))
+}
+
+/// GET /api/downloads/:id — 获取下载链接
+pub async fn get_download(
+ user: CurrentUser,
+ State(state): State,
+ Path(id): Path,
+) -> Result, AppError> {
+ auth::check_permission(&user, "charge_record:export")?;
+ let row: Option = sqlx::query_as(
+ "SELECT id, task_type, status, file_name, file_path, progress, \
+ error_message, created_at, completed_at \
+ FROM download_tasks WHERE id = ?"
+ )
+ .bind(id)
+ .fetch_optional(&state.mysql)
+ .await?;
+
+ let row = row.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?;
+
+ if row.status != 2 {
+ return Err(AppError::BadRequest("任务尚未完成".into()));
+ }
+
+ let file_path = row.file_path.unwrap_or_default();
+
+ // 检查文件是否存在
+ if !std::path::Path::new(&file_path).exists() {
+ return Err(AppError::NotFound("文件不存在或已清理".into()));
+ }
+
+ // 返回文件下载路径(后续可改为签名URL)
+ let download_url = format!("/api/downloads/{}/file", id);
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "id": row.id,
+ "file_name": row.file_name,
+ "download_url": download_url,
+ },
+ "message": "ok"
+ })))
+}
+
+/// GET /api/downloads/:id/file — 下载文件(二进制流)
+pub async fn download_file(
+ user: CurrentUser,
+ State(state): State,
+ Path(id): Path,
+) -> Result, AppError> {
+ auth::check_permission(&user, "charge_record:export")?;
+
+ let db = &state.mysql;
+
+ // 查询下载任务(限定本人)
+ let task: Option = sqlx::query_as(
+ "SELECT id, task_type, status, file_name, file_path, progress, \
+ error_message, created_at, completed_at \
+ FROM download_tasks WHERE id = ? AND user_id = ?",
+ )
+ .bind(id)
+ .bind(user.user_id)
+ .fetch_optional(db)
+ .await?;
+
+ let task = task.ok_or_else(|| AppError::NotFound("下载任务不存在".into()))?;
+
+ if task.status != 2 {
+ return Err(AppError::BadRequest("文件未生成完成".into()));
+ }
+
+ let file_path = task
+ .file_path
+ .ok_or_else(|| AppError::NotFound("文件路径不存在".into()))?;
+
+ // 读取文件内容
+ let file_content = tokio::fs::read(&file_path).await.map_err(|e| {
+ tracing::error!("读取下载文件失败 path={} err={}", file_path, e);
+ AppError::Internal(format!("读取文件失败: {}", e))
+ })?;
+
+ // 推断文件名
+ let file_name = task.file_name.unwrap_or_else(|| "download.xlsx".to_string());
+
+ // 构建文件下载响应
+ let response = Response::builder()
+ .status(200)
+ .header(
+ header::CONTENT_TYPE,
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ )
+ .header(
+ header::CONTENT_DISPOSITION,
+ format!("attachment; filename=\"{}\"", file_name),
+ )
+ .body(Body::from(file_content))
+ .map_err(|e| AppError::Internal(format!("构建响应失败: {}", e)))?;
+
+ Ok(response)
+}
+
+/// POST /api/downloads/cleanup — 清理过期文件
+pub async fn cleanup_downloads(
+ user: CurrentUser,
+ State(state): State,
+ Json(req): Json,
+) -> Result, AppError> {
+ auth::check_permission(&user, "charge_record:export")?;
+ // 查询过期任务(状态为完成且超过保留天数)
+ let expired: Vec = sqlx::query_as(
+ "SELECT id, task_type, status, file_name, file_path, progress, \
+ error_message, created_at, completed_at \
+ FROM download_tasks \
+ WHERE status = 2 AND completed_at < DATE_SUB(NOW(), INTERVAL ? DAY)"
+ )
+ .bind(req.retain_days)
+ .fetch_all(&state.mysql)
+ .await?;
+
+ let mut cleaned = 0u32;
+
+ for task in &expired {
+ // 删除物理文件
+ if let Some(ref path) = task.file_path {
+ let _ = tokio::fs::remove_file(path).await;
+ }
+
+ // 更新数据库记录
+ let _ = sqlx::query(
+ "UPDATE download_tasks SET file_path = NULL, status = 3, \
+ error_message = '文件已过期清理' WHERE id = ?"
+ )
+ .bind(task.id)
+ .execute(&state.mysql)
+ .await;
+
+ cleaned += 1;
+ }
+
+ tracing::info!("清理过期下载文件 {} 个", cleaned);
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": { "cleaned": cleaned },
+ "message": format!("已清理 {} 个过期文件", cleaned)
+ })))
+}
diff --git a/software/api-server/src/routes/energy_stats.rs b/software/api-server/src/routes/energy_stats.rs
new file mode 100644
index 0000000..c17190f
--- /dev/null
+++ b/software/api-server/src/routes/energy_stats.rs
@@ -0,0 +1,553 @@
+//! 能耗管理 API — 总览、排行、趋势、导出
+//!
+//! 基于 energy_stats 表按日期聚合的能耗数据进行统计分析。
+//! 电费估算按 ¥0.80/kWh 计算(可配置)。
+
+use axum::extract::{Query, State};
+use axum::Json;
+use serde::Deserialize;
+use serde_json::{json, Value};
+
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+
+/// 电费单价(元/kWh)
+const COST_PER_KWH: f64 = 0.80;
+
+/// 趋势查询参数
+#[derive(Debug, Deserialize)]
+pub struct TrendQuery {
+ /// day | week | month
+ #[serde(default = "default_granularity")]
+ pub granularity: String,
+ /// 天数范围,默认30天
+ #[serde(default = "default_range")]
+ pub range: i64,
+}
+
+/// 导出查询参数
+#[derive(Debug, Deserialize)]
+pub struct ExportQuery {
+ pub cabinet_id: Option,
+ pub project_id: Option,
+ pub start_date: Option,
+ pub end_date: Option,
+}
+
+/// 柜子排行行
+#[derive(Debug, sqlx::FromRow)]
+struct CabinetRankRow {
+ id: i64,
+ abstract_id: String,
+ name: Option,
+ today_energy: Option,
+ month_energy: Option,
+}
+
+/// 项目统计行
+#[derive(Debug, sqlx::FromRow)]
+struct ProjectStatRow {
+ id: i64,
+ name: String,
+ cabinet_count: i64,
+ today_energy: Option,
+ month_energy: Option,
+}
+
+/// 趋势数据行
+#[derive(Debug, sqlx::FromRow)]
+struct TrendRow {
+ period: String, // 日期或周/月标识
+ energy: Option,
+ charge_count: Option,
+}
+
+/// 能耗明细行(用于导出)
+#[derive(Debug, sqlx::FromRow)]
+struct EnergyDetailRow {
+ stat_date: chrono::NaiveDate,
+ cabinet_id: i64,
+ cabinet_name: Option,
+ project_name: Option,
+ total_energy: Option,
+ charge_count: Option,
+}
+
+fn default_granularity() -> String { "day".to_string() }
+fn default_range() -> i64 { 30 }
+
+/// GET /api/energy-stats/summary — 总览
+///
+/// 返回今日/本月能耗、电费估算、柜子/在线数量。
+pub async fn get_summary(
+ user: CurrentUser,
+ State(state): State,
+) -> Result, AppError> {
+ auth::check_permission(&user, "energy:view")?;
+ let pool = &state.mysql;
+
+ // 组织过滤子查询
+ let (org_filter, org_binds): (String, Vec) = if user.role_level >= 2 {
+ (String::new(), vec![])
+ } else if let Some(oid) = user.organization_id {
+ (
+ " AND es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(),
+ vec![oid.to_string()],
+ )
+ } else {
+ (" AND 1=0".to_string(), vec![])
+ };
+
+ // 今日总能耗
+ let today_sql = format!(
+ "SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es WHERE stat_date = CURDATE(){}",
+ org_filter
+ );
+ let mut today_q = sqlx::query_scalar::<_, f64>(&today_sql);
+ for v in &org_binds { today_q = today_q.bind(v); }
+ let today_energy: f64 = today_q.fetch_one(pool).await?;
+
+ // 本月总能耗
+ let month_sql = format!(
+ "SELECT COALESCE(SUM(total_energy), 0) FROM energy_stats es \
+ WHERE stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01'){}",
+ org_filter
+ );
+ let mut month_q = sqlx::query_scalar::<_, f64>(&month_sql);
+ for v in &org_binds { month_q = month_q.bind(v); }
+ let month_energy: f64 = month_q.fetch_one(pool).await?;
+
+ // 柜子总数(含组织过滤)
+ let cabinet_count: i64 = if user.role_level >= 2 {
+ sqlx::query_scalar("SELECT COUNT(*) FROM cabinets")
+ .fetch_one(pool).await?
+ } else if let Some(oid) = user.organization_id {
+ sqlx::query_scalar(
+ "SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?"
+ )
+ .bind(oid)
+ .fetch_one(pool).await?
+ } else {
+ 0
+ };
+
+ // 在线柜子数
+ let online_count: i64 = if user.role_level >= 2 {
+ sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
+ .fetch_one(pool).await?
+ } else if let Some(oid) = user.organization_id {
+ sqlx::query_scalar(
+ "SELECT COUNT(*) FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE c.status = 1 AND p.organization_id = ?"
+ )
+ .bind(oid)
+ .fetch_one(pool).await?
+ } else {
+ 0
+ };
+
+ let today = today_energy;
+ let month = month_energy;
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "today_energy": (today * 100.0).round() / 100.0,
+ "month_energy": (month * 100.0).round() / 100.0,
+ "estimated_cost": ((month * COST_PER_KWH) * 100.0).round() / 100.0,
+ "cabinet_count": cabinet_count,
+ "online_count": online_count,
+ },
+ "message": "ok"
+ })))
+}
+
+/// GET /api/energy-stats/cabinets — 柜子能耗排行
+///
+/// 按今日能耗降序排列,返回柜子列表及今日/本月能耗。
+pub async fn get_cabinet_ranking(
+ user: CurrentUser,
+ State(state): State,
+) -> Result, AppError> {
+ auth::check_permission(&user, "energy:view")?;
+ let pool = &state.mysql;
+
+ let (org_join, org_binds): (String, Vec) = if user.role_level >= 2 {
+ (String::new(), vec![])
+ } else if let Some(oid) = user.organization_id {
+ (
+ " JOIN projects p ON c.project_id = p.id AND p.organization_id = ?".to_string(),
+ vec![oid.to_string()],
+ )
+ } else {
+ (" JOIN projects p ON 1=0".to_string(), vec![])
+ };
+
+ let sql = format!(
+ "SELECT c.id, c.abstract_id, c.name, \
+ COALESCE(es_today.total_energy, 0) AS today_energy, \
+ COALESCE(es_month.total_energy, 0) AS month_energy \
+ FROM cabinets c \
+ LEFT JOIN energy_stats es_today \
+ ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
+ LEFT JOIN energy_stats es_month \
+ ON c.id = es_month.cabinet_id \
+ AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
+ {} \
+ ORDER BY today_energy DESC, c.id ASC",
+ org_join
+ );
+ let mut query = sqlx::query_as::<_, CabinetRankRow>(&sql);
+ for v in &org_binds { query = query.bind(v); }
+ let rows = query.fetch_all(pool).await?;
+
+ let list: Vec = rows.iter().map(|r| {
+ json!({
+ "cabinet_id": r.id,
+ "abstract_id": r.abstract_id,
+ "name": r.name,
+ "today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
+ "month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
+ })
+ }).collect();
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": list,
+ "message": "ok"
+ })))
+}
+
+/// GET /api/energy-stats/projects — 项目能耗统计
+///
+/// 按今日能耗降序排列,返回项目级聚合数据。
+pub async fn get_project_stats(
+ user: CurrentUser,
+ State(state): State,
+) -> Result, AppError> {
+ auth::check_permission(&user, "energy:view")?;
+ let pool = &state.mysql;
+
+ let (org_filter, org_binds): (String, Vec) = if user.role_level >= 2 {
+ (String::new(), vec![])
+ } else if let Some(oid) = user.organization_id {
+ (" WHERE p.organization_id = ?".to_string(), vec![oid.to_string()])
+ } else {
+ (" WHERE 1=0".to_string(), vec![])
+ };
+
+ let sql = format!(
+ "SELECT p.id, p.name, \
+ COUNT(DISTINCT c.id) AS cabinet_count, \
+ COALESCE(SUM(es_today.total_energy), 0) AS today_energy, \
+ COALESCE(SUM(es_month.total_energy), 0) AS month_energy \
+ FROM projects p \
+ LEFT JOIN cabinets c ON p.id = c.project_id \
+ LEFT JOIN energy_stats es_today \
+ ON c.id = es_today.cabinet_id AND es_today.stat_date = CURDATE() \
+ LEFT JOIN energy_stats es_month \
+ ON c.id = es_month.cabinet_id \
+ AND es_month.stat_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') \
+ {} \
+ GROUP BY p.id, p.name \
+ ORDER BY today_energy DESC, p.id ASC",
+ org_filter
+ );
+ let mut query = sqlx::query_as::<_, ProjectStatRow>(&sql);
+ for v in &org_binds { query = query.bind(v); }
+ let rows = query.fetch_all(pool).await?;
+
+ let list: Vec = rows.iter().map(|r| {
+ json!({
+ "project_id": r.id,
+ "name": r.name,
+ "cabinet_count": r.cabinet_count,
+ "today_energy": (r.today_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
+ "month_energy": (r.month_energy.unwrap_or(0.0) * 100.0).round() / 100.0,
+ })
+ }).collect();
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": list,
+ "message": "ok"
+ })))
+}
+
+/// GET /api/energy-stats/trend — 趋势数据
+///
+/// granularity: day | week | month, 默认 day
+/// range: 天数范围, 默认 30
+pub async fn get_trend(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "energy:view")?;
+ let pool = &state.mysql;
+
+ // 组织过滤:es.cabinet_id → cabinets.project_id → projects.organization_id
+ let (org_filter, org_binds): (String, Vec) = if user.role_level >= 2 {
+ (String::new(), vec![])
+ } else if let Some(oid) = user.organization_id {
+ (
+ " AND es.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id WHERE p.organization_id = ?)".to_string(),
+ vec![oid.to_string()],
+ )
+ } else {
+ (" AND 1=0".to_string(), vec![])
+ };
+
+ let rows = match params.granularity.as_str() {
+ "week" => {
+ let sql = format!(
+ "SELECT DATE_FORMAT(stat_date, '%Y-%u') AS period, \
+ SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
+ FROM energy_stats es \
+ WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
+ GROUP BY period ORDER BY period ASC",
+ org_filter
+ );
+ let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
+ for v in &org_binds { q = q.bind(v); }
+ q.fetch_all(pool).await?
+ }
+ "month" => {
+ let sql = format!(
+ "SELECT DATE_FORMAT(stat_date, '%Y-%m') AS period, \
+ SUM(total_energy) AS energy, SUM(charge_count) AS charge_count \
+ FROM energy_stats es \
+ WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
+ GROUP BY period ORDER BY period ASC",
+ org_filter
+ );
+ let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
+ for v in &org_binds { q = q.bind(v); }
+ q.fetch_all(pool).await?
+ }
+ _ => {
+ // day
+ let sql = format!(
+ "SELECT stat_date AS period, \
+ total_energy AS energy, charge_count AS charge_count \
+ FROM energy_stats es \
+ WHERE stat_date >= DATE_SUB(CURDATE(), INTERVAL ? DAY){} \
+ ORDER BY stat_date ASC",
+ org_filter
+ );
+ let mut q = sqlx::query_as::<_, TrendRow>(&sql).bind(params.range);
+ for v in &org_binds { q = q.bind(v); }
+ q.fetch_all(pool).await?
+ }
+ };
+
+ let list: Vec = rows.iter().map(|r| {
+ json!({
+ "period": r.period,
+ "energy": (r.energy.unwrap_or(0.0) * 100.0).round() / 100.0,
+ "charge_count": r.charge_count.unwrap_or(0),
+ })
+ }).collect();
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "granularity": params.granularity,
+ "list": list,
+ },
+ "message": "ok"
+ })))
+}
+
+/// GET /api/energy-stats/export — 异步导出能耗报表
+pub async fn export_energy_stats(
+ user: CurrentUser,
+ State(state): State,
+ Query(params): Query,
+) -> Result, AppError> {
+ auth::check_permission(&user, "energy:export")?;
+ let user_id = user.user_id;
+ let file_name = generate_export_filename(¶ms);
+
+ // 序列化查询参数
+ let query_json = serde_json::to_string(&json!({
+ "cabinet_id": params.cabinet_id,
+ "project_id": params.project_id,
+ "start_date": params.start_date,
+ "end_date": params.end_date,
+ }))
+ .unwrap_or_default();
+
+ // 插入下载任务
+ let result = sqlx::query(
+ "INSERT INTO download_tasks (user_id, task_type, status, file_name, error_message) \
+ VALUES (?, ?, 0, ?, ?)"
+ )
+ .bind(user_id)
+ .bind("energy_stats")
+ .bind(&file_name)
+ .bind(&query_json)
+ .execute(&state.mysql)
+ .await?;
+
+ let task_id = result.last_insert_id() as i64;
+
+ // 后台异步生成 Excel
+ let pool = state.mysql.clone();
+ tokio::spawn(async move {
+ if let Err(e) = generate_energy_excel(pool, task_id).await {
+ tracing::error!("导出能耗统计失败 task_id={}: {}", task_id, e);
+ }
+ });
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": { "task_id": task_id },
+ "message": "导出任务已创建"
+ })))
+}
+
+/// 生成导出文件名
+fn generate_export_filename(params: &ExportQuery) -> String {
+ let start = params
+ .start_date
+ .as_ref()
+ .map(|s| s.replace('-', ""))
+ .unwrap_or_default();
+ let end = params
+ .end_date
+ .as_ref()
+ .map(|s| s.replace('-', ""))
+ .unwrap_or_default();
+ format!("能耗统计_{}-{}.xlsx", start, end)
+}
+
+/// 构建导出条件
+fn build_export_clause(
+ cabinet_id: Option,
+ project_id: Option,
+ start_date: Option<&str>,
+ end_date: Option<&str>,
+) -> (String, Vec) {
+ let mut conditions = Vec::new();
+ let mut bind_values = Vec::new();
+
+ if let Some(id) = cabinet_id {
+ conditions.push("es.cabinet_id = ?");
+ bind_values.push(id.to_string());
+ }
+ if let Some(id) = project_id {
+ conditions.push("c.project_id = ?");
+ bind_values.push(id.to_string());
+ }
+ if let Some(d) = start_date {
+ conditions.push("es.stat_date >= ?");
+ bind_values.push(d.to_string());
+ }
+ if let Some(d) = end_date {
+ conditions.push("es.stat_date <= ?");
+ bind_values.push(d.to_string());
+ }
+
+ let clause = if conditions.is_empty() {
+ String::new()
+ } else {
+ format!("WHERE {}", conditions.join(" AND "))
+ };
+ (clause, bind_values)
+}
+
+/// 后台异步生成能耗 Excel 文件
+async fn generate_energy_excel(
+ pool: sqlx::MySqlPool,
+ task_id: i64,
+) -> Result<(), Box> {
+ // 更新状态为处理中
+ sqlx::query("UPDATE download_tasks SET status = 1 WHERE id = ?")
+ .bind(task_id)
+ .execute(&pool)
+ .await?;
+
+ // 获取查询参数
+ let row: Option<(String, String)> = sqlx::query_as(
+ "SELECT file_name, error_message FROM download_tasks WHERE id = ?"
+ )
+ .bind(task_id)
+ .fetch_optional(&pool)
+ .await?;
+
+ let (file_name, query_json) = row.ok_or("任务不存在")?;
+ let params: Value = serde_json::from_str(&query_json).unwrap_or(json!({}));
+
+ let (where_clause, bind_values) = build_export_clause(
+ params.get("cabinet_id").and_then(|v| v.as_i64()),
+ params.get("project_id").and_then(|v| v.as_i64()),
+ params.get("start_date").and_then(|v| v.as_str()),
+ params.get("end_date").and_then(|v| v.as_str()),
+ );
+
+ // 查询能耗明细
+ let sql = format!(
+ "SELECT es.stat_date, es.cabinet_id, c.name AS cabinet_name, \
+ p.name AS project_name, \
+ CAST(es.total_energy AS DOUBLE) AS total_energy, \
+ es.charge_count \
+ FROM energy_stats es \
+ LEFT JOIN cabinets c ON es.cabinet_id = c.id \
+ LEFT JOIN projects p ON c.project_id = p.id \
+ {} ORDER BY es.stat_date DESC, es.cabinet_id ASC",
+ where_clause
+ );
+
+ let mut query = sqlx::query_as::<_, EnergyDetailRow>(&sql);
+ for val in &bind_values {
+ query = query.bind(val);
+ }
+ let rows = query.fetch_all(&pool).await?;
+
+ // 更新进度 50%
+ sqlx::query("UPDATE download_tasks SET progress = 50 WHERE id = ?")
+ .bind(task_id)
+ .execute(&pool)
+ .await?;
+
+ // 生成 Excel
+ let mut workbook = rust_xlsxwriter::Workbook::new();
+ let sheet = workbook.add_worksheet();
+ sheet.set_name("能耗统计")?;
+
+ let headers = [
+ "日期", "柜子ID", "柜子名称", "所属项目", "能耗(kWh)", "充电次数",
+ ];
+ for (col, header) in headers.iter().enumerate() {
+ sheet.write_string(0, col as u16, *header)?;
+ }
+
+ for (row_idx, record) in rows.iter().enumerate() {
+ let row = (row_idx + 1) as u32;
+ sheet.write_string(row, 0, record.stat_date.to_string())?;
+ sheet.write_number(row, 1, record.cabinet_id as f64)?;
+ sheet.write_string(row, 2, record.cabinet_name.as_deref().unwrap_or("-"))?;
+ sheet.write_string(row, 3, record.project_name.as_deref().unwrap_or("-"))?;
+ sheet.write_number(row, 4, record.total_energy.unwrap_or(0.0))?;
+ sheet.write_number(row, 5, record.charge_count.unwrap_or(0) as f64)?;
+ }
+
+ // 保存文件
+ let file_dir = "./exports";
+ tokio::fs::create_dir_all(file_dir).await?;
+ let file_path = format!("{}/{}", file_dir, file_name);
+ workbook.save(&file_path)?;
+
+ // 更新任务状态为完成
+ sqlx::query(
+ "UPDATE download_tasks SET status = 2, progress = 100, file_path = ?, \
+ error_message = NULL, completed_at = NOW() WHERE id = ?"
+ )
+ .bind(&file_path)
+ .bind(task_id)
+ .execute(&pool)
+ .await?;
+
+ tracing::info!("能耗统计导出完成 task_id={} file={}", task_id, file_path);
+ Ok(())
+}
diff --git a/software/api-server/src/routes/h5.rs b/software/api-server/src/routes/h5.rs
new file mode 100644
index 0000000..f8a6ff6
--- /dev/null
+++ b/software/api-server/src/routes/h5.rs
@@ -0,0 +1,990 @@
+//! H5 用户端 API
+//!
+//! 提供微信扫码充电的 H5 页面所需的全部后端接口:
+//! - POST /api/h5/auth/login — H5 手机号登录
+//! - GET /api/h5/dashboard — 首页仪表盘数据
+//! - GET /api/h5/projects — 项目列表
+//! - GET /api/h5/cabinets — 设备列表(按项目过滤)
+//! - GET /api/h5/cabinets/:id — 设备详情(含仓板+仓体状态)
+//! - GET /api/h5/cabinets/abstract/:aid — 通过抽象ID查设备(扫码入口)
+//! - GET /api/h5/compartments/:id — 仓体/通道详情
+//! - POST /api/h5/charge/start — 开始充电
+//! - POST /api/h5/charge/stop — 停止充电
+//! - POST /api/h5/door/open — 开门
+
+use axum::extract::{Path, Query, State};
+use axum::Json;
+use serde::Deserialize;
+use serde_json::{json, Value};
+
+use crate::commands::{AppState, DeviceCommand};
+use crate::error::AppError;
+use crate::middleware::auth::{self, CurrentUser};
+
+// ---------------------------------------------------------------------------
+// 请求体定义
+// ---------------------------------------------------------------------------
+
+/// H5 登录请求体
+#[derive(Debug, Deserialize)]
+pub(crate) struct H5LoginInput {
+ phone: String,
+ password: String,
+}
+
+/// 充电控制请求体(开始/停止充电、开门共用)
+#[derive(Debug, Deserialize)]
+pub(crate) struct CompartmentActionInput {
+ compartment_id: i64,
+}
+
+/// 设备列表查询参数
+#[derive(Debug, Deserialize)]
+pub(crate) struct CabinetListQuery {
+ project_id: Option,
+}
+
+// ---------------------------------------------------------------------------
+// 行映射结构体
+// ---------------------------------------------------------------------------
+
+/// 项目简要信息行
+#[derive(Debug, sqlx::FromRow)]
+struct ProjectRow {
+ id: i64,
+ name: String,
+}
+
+/// 柜子列表行
+#[derive(Debug, sqlx::FromRow)]
+struct H5CabinetRow {
+ id: i64,
+ abstract_id: String,
+ name: Option,
+ status: i8,
+ project_id: Option,
+}
+
+/// 仓控板行
+#[derive(Debug, sqlx::FromRow)]
+struct H5CabinBoardRow {
+ id: i64,
+ board_id: i8,
+ status: i8,
+}
+
+/// 仓体行
+#[derive(Debug, sqlx::FromRow)]
+struct H5CompartmentRow {
+ id: i64,
+ channel_id: i8,
+ status: i8,
+}
+
+/// 充电记录简要行
+#[derive(Debug, sqlx::FromRow)]
+struct ChargeRecordBriefRow {
+ id: i64,
+ start_time: Option,
+ end_time: Option,
+ energy: Option,
+}
+
+/// 柜子 IMEI 查询行(用于指令下发)
+#[derive(Debug, sqlx::FromRow)]
+struct CabinetImeiRow {
+ imei: String,
+}
+
+// ---------------------------------------------------------------------------
+// H5 登录
+// ---------------------------------------------------------------------------
+
+/// POST /api/h5/auth/login — H5 用户登录(复用 users 表)
+pub async fn h5_login(
+ State(state): State,
+ Json(input): Json,
+) -> Result, AppError> {
+ let pool = &state.mysql;
+
+ if input.phone.is_empty() || input.password.is_empty() {
+ return Err(AppError::BadRequest("手机号和密码不能为空".into()));
+ }
+
+ // 查询用户
+ let user_row: Option<(i64, Option, String, String, i32)> = sqlx::query_as(
+ "SELECT id, name, phone, password, status FROM users WHERE phone = ?",
+ )
+ .bind(&input.phone)
+ .fetch_optional(pool)
+ .await?;
+
+ let (id, name, phone, stored_password, status) =
+ user_row.ok_or_else(|| AppError::Unauthorized("手机号或密码错误".into()))?;
+
+ if status == 0 {
+ return Err(AppError::Forbidden("账户已被禁用".into()));
+ }
+
+ let valid = auth::verify_password(&input.password, &stored_password)
+ .map_err(|_| AppError::Internal("密码验证失败".into()))?;
+ if !valid {
+ return Err(AppError::Unauthorized("手机号或密码错误".into()));
+ }
+
+ let token = auth::generate_token(id, &phone)?;
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "token": token,
+ "user": {
+ "id": id,
+ "phone": phone,
+ "name": name,
+ }
+ },
+ "message": "登录成功"
+ })))
+}
+
+// ---------------------------------------------------------------------------
+// 仪表盘
+// ---------------------------------------------------------------------------
+
+/// GET /api/h5/dashboard — H5 首页仪表盘数据(带组织隔离)
+pub async fn get_dashboard(
+ user: CurrentUser,
+ State(state): State,
+) -> Result, AppError> {
+ let pool = &state.mysql;
+
+ // 根据角色确定组织过滤条件
+ let org_id = user.organization_id;
+ let is_super = user.role_level >= 2;
+
+ // 统计在线柜子数(带组织隔离)
+ let online_cabinets = count_online_cabinets(pool, is_super, org_id).await?;
+
+ // 统计充电中仓体数(带组织隔离)
+ let charging_count = count_compartments_by_status(pool, is_super, org_id, 1).await?;
+
+ // 统计空闲仓体数(带组织隔离)
+ let idle_channels = count_compartments_by_status(pool, is_super, org_id, 0).await?;
+
+ // 统计故障仓体数(带组织隔离)
+ let fault_channels = count_compartments_by_status(pool, is_super, org_id, 2).await?;
+
+ // 今日充电次数(带组织隔离)
+ let today_charge_count = count_today_charges(pool, is_super, org_id).await?;
+
+ // 今日充电量(kWh,带组织隔离)
+ let today_energy = sum_today_energy(pool, is_super, org_id).await?;
+
+ // 今日充电时长(小时,带组织隔离)
+ let today_charge_hours = sum_today_charge_hours(pool, is_super, org_id).await?;
+
+ // 故障告警(取最近10条故障仓体,返回项目名+柜名)
+ let alerts = fetch_fault_alerts(pool, is_super, org_id).await?;
+
+ // 项目摘要
+ let projects = fetch_project_summaries(pool, &user).await?;
+
+ Ok(Json(json!({
+ "code": 0,
+ "data": {
+ "online_cabinets": online_cabinets,
+ "charging_count": charging_count,
+ "idle_channels": idle_channels,
+ "fault_channels": fault_channels,
+ "today_charge_count": today_charge_count,
+ "today_energy_kwh": today_energy,
+ "today_charge_hours": today_charge_hours,
+ "alerts": alerts,
+ "projects": projects,
+ },
+ "message": "ok"
+ })))
+}
+
+/// 统计在线柜子数(带组织隔离)
+async fn count_online_cabinets(
+ pool: &sqlx::MySqlPool,
+ is_super: bool,
+ org_id: Option,
+) -> Result {
+ if !is_super && org_id.is_none() {
+ return Ok(0);
+ }
+ let count: i64 = if is_super {
+ sqlx::query_scalar("SELECT COUNT(*) FROM cabinets WHERE status = 1")
+ .fetch_one(pool)
+ .await?
+ } else {
+ sqlx::query_scalar(
+ r#"SELECT COUNT(*) FROM cabinets cab
+ INNER JOIN projects p ON cab.project_id = p.id
+ WHERE cab.status = 1 AND p.organization_id = ?"#,
+ )
+ .bind(org_id.unwrap())
+ .fetch_one(pool)
+ .await?
+ };
+ Ok(count)
+}
+
+/// 按状态统计仓体数(带组织隔离,通过 cabin_boards → cabinets → projects 链路)
+async fn count_compartments_by_status(
+ pool: &sqlx::MySqlPool,
+ is_super: bool,
+ org_id: Option,
+ status: i8,
+) -> Result {
+ if !is_super && org_id.is_none() {
+ return Ok(0);
+ }
+ let count: i64 = if is_super {
+ sqlx::query_scalar("SELECT COUNT(*) FROM compartments WHERE status = ?")
+ .bind(status)
+ .fetch_one(pool)
+ .await?
+ } else {
+ sqlx::query_scalar(
+ r#"SELECT COUNT(*) FROM compartments comp
+ INNER JOIN cabin_boards cb ON comp.cabin_board_id = cb.id
+ INNER JOIN cabinets cab ON cb.cabinet_id = cab.id
+ INNER JOIN projects p ON cab.project_id = p.id
+ WHERE comp.status = ? AND p.organization_id = ?"#,
+ )
+ .bind(status)
+ .bind(org_id.unwrap())
+ .fetch_one(pool)
+ .await?
+ };
+ Ok(count)
+}
+
+/// 今日充电次数(带组织隔离)
+async fn count_today_charges(
+ pool: &sqlx::MySqlPool,
+ is_super: bool,
+ org_id: Option,
+) -> Result {
+ if !is_super && org_id.is_none() {
+ return Ok(0);
+ }
+ let count: i64 = if is_super {
+ sqlx::query_scalar(
+ "SELECT COUNT(*) FROM charge_records WHERE DATE(start_time) = CURDATE()",
+ )
+ .fetch_one(pool)
+ .await?
+ } else {
+ sqlx::query_scalar(
+ r#"SELECT COUNT(*) FROM charge_records cr
+ INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
+ INNER JOIN projects p ON cab.project_id = p.id
+ WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
+ )
+ .bind(org_id.unwrap())
+ .fetch_one(pool)
+ .await?
+ };
+ Ok(count)
+}
+
+/// 今日充电量(kWh,带组织隔离)
+async fn sum_today_energy(
+ pool: &sqlx::MySqlPool,
+ is_super: bool,
+ org_id: Option,
+) -> Result {
+ if !is_super && org_id.is_none() {
+ return Ok(0.0);
+ }
+ let energy: Option = if is_super {
+ sqlx::query_scalar(
+ "SELECT CAST(SUM(energy) AS DOUBLE) FROM charge_records WHERE DATE(start_time) = CURDATE()",
+ )
+ .fetch_one(pool)
+ .await?
+ } else {
+ sqlx::query_scalar(
+ r#"SELECT CAST(SUM(cr.energy) AS DOUBLE) FROM charge_records cr
+ INNER JOIN cabinets cab ON cr.cabinet_id = cab.id
+ INNER JOIN projects p ON cab.project_id = p.id
+ WHERE DATE(cr.start_time) = CURDATE() AND p.organization_id = ?"#,
+ )
+ .bind(org_id.unwrap())
+ .fetch_one(pool)
+ .await?
+ };
+ Ok(energy.unwrap_or(0.0))
+}
+
+/// 今日充电时长(小时,带组织隔离)
+async fn sum_today_charge_hours(
+ pool: &sqlx::MySqlPool,
+ is_super: bool,
+ org_id: Option