docs: 文档分类整理 —— AGENTS.md 只留判据与链接,详细篇按用途归入 docs/ 子目录
## 为什么要做
`AGENTS.md` 曾达 **90,370 B**,而工作区指令预算是 **65,536 B** —— 系统按**保留头部**截断,
于是**尾部约 25 KB 永远不会被加载**:组织架构、数据模型、TCP 协议、数据单位规范、
微信小程序用户端**全部处于"写了但读不到"的状态**。手册实际上部分失效。
## 一、AGENTS.md 瘦身:90,370 → 60,227 B(余量 5,309 B,尾部不再被截断)
三个**纯参考型大段**整段搬迁到 `docs/tech/`(**内容逐字保留,未删改**),
原位置换成「判据 + 链接」:
| 原 AGENTS.md 章节 | 大小 | 现位置 |
|---|---|---|
| `## 部署与维护` | 17,611 B | `docs/tech/部署手册.md` |
| `## 数据模型` | 12,100 B | `docs/tech/数据模型.md` |
| `## 微信小程序用户端` | 10,262 B | `docs/tech/小程序用户端.md` |
替换后的章节**只留改动前必须先知道的硬约束**(例如:产物名与测试环境落地名不一致、
ETXTBSY 的 `.new`→`stop`→`mv`→`start` 流程、`stream{}` 不可重加、发布顺序 device 先 api 后、
建表顺序即依赖顺序且写错报 MySQL 1824、时间列必须 DATETIME、`cabinets.status` 是配置态不表示在线……),
其余指向详细篇。**判据留在常驻加载的 AGENTS.md,逐项参考移出**。
## 二、docs/ 分类(原 21 个文件平铺 → 五个用途目录)
```
docs/
├── README.md 新增:文档地图(分类/权威性/旧名→现名映射/维护约定)
├── protocol/ 设备协议(冻结,对硬件交付)★唯一权威
│ ├── 柜控板固件对接文档.md
│ └── 1TN协议标准_*.md / *.pdf
├── tech/ 平台技术文档(开发/运维必读)
│ ├── API.md · 数据模型.md · 部署手册.md · 小程序用户端.md
├── contract/ 合同·规格·验收标准(商务依据,不描述实现)
├── compound-notes/ 单点经验笔记(原有)
└── archive/ ⛔ 历史留痕(只读,不代表现状,不可作依据)
├── 开发文档.md(早于现行实现)
└── 审查报告/深度分析报告-2026-07-06
```
- 19 个文件用 `git mv` 迁移(保留历史),3 个未跟踪文件用普通移动;
- **内容零改动**(唯一例外是引用替换),已核验 `docs/tech/API.md` 的 `git diff` 为空。
## 三、全仓引用同步:40 处 / 10 个文件
路径不只出现在文档里,还散落在**源码注释**(`db/migrate.rs`、`routes/cabinets.rs`、
`workers/report_worker.rs`、`tcp/connection.rs`、`h5-uniapp/utils/format.ts`)、
`test/tcp_e2e_smoke.py`、`tasks/001-*.md`、`README.md`、`AGENTS.md` 中,全部同步。
**并已实测校验命令能失败**:往 `docs/tech/API.md` 植入 `<!-- probe: docs/API.md -->`
→ 命中;删除 → 无输出。
> ⚠️ 过程中踩了一个坑并已修:这些 `.md` 多为 **CRLF**,我第一次用 `\n` 正则删除时
> **没匹配上 `\r\n`**,以为删干净了其实残留了一行 —— 靠 `git diff` 非空才发现。
> 已把「CRLF 必须按 `\r?\n` 处理」写进 `docs/README.md` 的维护约定。
## 四、顺带查实的失效引用(归档保留原文,不改写历史)
- `docs/tcp-protocol.md`:仅出现在 2026-07-06 的两份历史报告里(当时的事实);
- `docs/柜控板-平台TCP接口文档.md`:仅出现在协议文档 **v1.1 变更记录**里
(记录"已删除该内部重复文档"这件事),**不是活链接**,无需修复。
两者已在 `docs/README.md` 的「旧名 → 现名映射」表中给出对应关系。
This commit is contained in:
parent
471b61d277
commit
619c96c85f
755
AGENTS.md
755
AGENTS.md
@ -28,7 +28,13 @@
|
||||
```
|
||||
D:\charging-cabinet\
|
||||
├── tasks/ # 任务文件(opencode执行)
|
||||
├── docs/ # 开发文档、协议文档
|
||||
├── docs/ # 📖 **文档地图见 docs/README.md**(按用途分类,勿再平铺)
|
||||
│ ├── README.md # 文档索引:分类、权威性、旧名→现名映射、维护约定
|
||||
│ ├── protocol/ # 设备协议(冻结,对硬件交付)→ 柜控板固件对接文档.md ⭐唯一权威
|
||||
│ ├── tech/ # 平台技术文档 → API.md / 数据模型.md / 部署手册.md / 小程序用户端.md
|
||||
│ ├── contract/ # 合同·规格·验收标准(商务依据,不描述实现)
|
||||
│ ├── compound-notes/ # 单点经验笔记(参考性)
|
||||
│ └── archive/ # ⛔ 历史留痕(只读,不代表现状,不可作依据)
|
||||
├── deploy/ # 部署配置与脚本
|
||||
│ ├── nginx/ # Nginx 配置镜像(**三份**:nginx.conf 主配置 + app/test 两份 conf.d,均与线上逐字节一致)
|
||||
│ ├── *.service # systemd 单元(pms-api / pms-device × prod·test 共 4 个)
|
||||
@ -667,7 +673,7 @@ test_check_permission_user_returns_forbidden
|
||||
- **TCP 连接登录门控(上行 + 下行,同一规则)**:同一条 TCP 连接在 `login` 成功前**只允许 `auth_str` / `login`**;
|
||||
其余任何 `act`(含 `status_post`、`bat_in`、`on`/`off`、`open`/`pow_on`/`pow_off`/`reset`、以及各类应答)
|
||||
**上行一律丢弃、下行一律不投递**(设备侧收不到任何字节)。
|
||||
依据:`docs/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。
|
||||
依据:`docs/protocol/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。
|
||||
**判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`,
|
||||
在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位),
|
||||
**不是** Redis 在线键 `device:{imei}` —— 两者回答的是**不同问题**(2026-09-20 逐行核对订正):
|
||||
@ -770,369 +776,71 @@ tracing::warn!(compartment_id = id, "仓体状态异常");
|
||||
|
||||
## 部署与维护
|
||||
|
||||
> 📖 **完整篇见 [docs/tech/部署手册.md](docs/tech/部署手册.md)** —— 含 nginx 两份 conf.d 全文、
|
||||
> 逐步部署命令、**全部 11 条部署注意事项**。本节只留"每次部署都必须先过一遍"的判据;
|
||||
> 搬迁原因:本文件超工作区指令预算(65,536 B)会被按头部截断,尾部不加载。
|
||||
|
||||
### SSH连接
|
||||
|
||||
```bash
|
||||
# 外网IP(推荐)
|
||||
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
|
||||
|
||||
echo y | plink -ssh root@118.31.119.206 -pw "<口令>"
|
||||
# ZeroTier内网IP
|
||||
echo y | plink -ssh root@10.8.0.252 -pw "Hbhyg731024@"
|
||||
echo y | plink -ssh root@10.8.0.252 -pw "<口令>"
|
||||
```
|
||||
|
||||
### 数据库连接
|
||||
### 部署前必看(致命项,逐条都栽过)
|
||||
|
||||
⛔ **不要再用 `10.8.0.252` 连库** —— 服务器 nginx `stream{}` 转发已于 2026-09-20 移除,
|
||||
`10.8.0.252:3306/6379`(以及 `127.0.0.1` 回环)**都连不到了**(实测 ✅)。
|
||||
**所有本地数据库操作一律先起 SSH 隧道**(`pwsh tools/dev-tunnel.ps1`),再连 `127.0.0.1:13306/16379`。
|
||||
|
||||
**MySQL(通过 SSH 隧道):**
|
||||
```bash
|
||||
# 先起隧道(另一个终端常驻)
|
||||
pwsh tools/dev-tunnel.ps1
|
||||
|
||||
# 命令行
|
||||
mysql -h 127.0.0.1 -P 13306 -u root -p
|
||||
# 密码:Hbhyg731024@
|
||||
# 测试环境库:pms_dev
|
||||
# 正式环境库:pms
|
||||
```
|
||||
|
||||
```python
|
||||
# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
|
||||
import os, pymysql
|
||||
host = os.environ.get('PMS_MYSQL_HOST', '127.0.0.1')
|
||||
port = int(os.environ.get('PMS_MYSQL_PORT', '13306'))
|
||||
conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms') # 正式库
|
||||
conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms_dev') # 测试库
|
||||
```
|
||||
|
||||
**Redis(通过 SSH 隧道):**
|
||||
```bash
|
||||
# 命令行
|
||||
redis-cli -h 127.0.0.1 -p 16379 -a "Hbhyg731024@"
|
||||
# 测试环境:select 6
|
||||
# 正式环境:select 5
|
||||
```
|
||||
|
||||
```python
|
||||
# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
|
||||
import os, redis
|
||||
host = os.environ.get('PMS_REDIS_HOST', '127.0.0.1')
|
||||
port = int(os.environ.get('PMS_REDIS_PORT', '16379'))
|
||||
r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=5) # 正式环境
|
||||
r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=6) # 测试环境
|
||||
```
|
||||
|
||||
**数据库工具 `tools/db.py`(需先起隧道):**
|
||||
```bash
|
||||
python tools/db.py tables # 列出所有表
|
||||
python tools/db.py count # 统计各表数据量
|
||||
python tools/db.py query "SELECT * FROM users" # 查询
|
||||
python tools/db.py exec "UPDATE users SET ..." # 写入
|
||||
```
|
||||
> **`tools/db.py` 默认连测试库 `pms_dev`**(可用 `PMS_MYSQL_DB=pms` 切生产,切换时会打印醒目警告);
|
||||
> 连接失败会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 exit 2。
|
||||
|
||||
### Nginx配置
|
||||
|
||||
> ⚠️ **下面是"骨架节选",不是线上全文** —— 线上配置还包含(2026-09 实测):
|
||||
> 安全响应头(`Strict-Transport-Security` / `X-Content-Type-Options` / `X-Frame-Options` /
|
||||
> `Referrer-Policy` / `Content-Security-Policy-Report-Only`)、
|
||||
> **限流 location**(`= /api/pms/auth/login`、`= /api/pms/auth/challenge`、`/api/pms/cabinets/abstract/`,配合
|
||||
> `/etc/nginx/nginx.conf` 里的 `limit_req_zone auth_login_zone / auth_challenge_zone / uploads_zone / scan_abstract_zone`)、
|
||||
> `location /uploads/`(免认证同源直出 + 限流 + `expires 30d`)、生产侧 `location = /pms { return 301 /pms/; }`、
|
||||
> 测试侧 `location /h5/`。
|
||||
> **改动前先拉线上真实文件为准**:
|
||||
> `plink -ssh root@118.31.119.206 "cat /etc/nginx/conf.d/app.anzhizhichong.com.conf"`。
|
||||
> 仓库内 `deploy/nginx/` 是**三份配置的镜像**(`nginx.conf` 主配置 + `app`/`test` 两份 conf.d,
|
||||
> 均与线上**逐字节一致**)—— 改线上前先比对,见「部署注意事项」第 10 条。
|
||||
|
||||
**正式环境** (`/etc/nginx/conf.d/app.anzhizhichong.com.conf`):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.anzhizhichong.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name app.anzhizhichong.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/app.anzhizhichong.com.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/app.anzhizhichong.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 产品介绍页
|
||||
root /data/landing;
|
||||
index index.html;
|
||||
|
||||
location /doc/ {
|
||||
proxy_pass http://127.0.0.1:3080/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
|
||||
# PMS API 入口(微服务统一前缀 /api/{服务})
|
||||
location /api/pms/ {
|
||||
proxy_pass http://127.0.0.1:9000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# PMS管理前端
|
||||
location /pms/ {
|
||||
alias /data/pms/web/;
|
||||
try_files $uri $uri/ /pms/index.html;
|
||||
}
|
||||
|
||||
# 微信回调(免认证)
|
||||
location /api/wechat/ {
|
||||
proxy_pass http://127.0.0.1:9000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**测试环境** (`/etc/nginx/conf.d/test.anzhizhichong.com.conf`):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name test.anzhizhichong.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name test.anzhizhichong.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/test.anzhizhichong.com.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/test.anzhizhichong.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
root /data/pms_dev;
|
||||
index index.html;
|
||||
|
||||
# PMS API 入口(微服务统一前缀 /api/{服务})
|
||||
location /api/pms/ {
|
||||
proxy_pass http://127.0.0.1:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /pms/ {
|
||||
try_files $uri $uri/ /pms/index.html;
|
||||
}
|
||||
|
||||
# 微信回调(免认证)
|
||||
location /api/wechat/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 测试环境SSL证书与正式环境共用(test是app证书的SAN域名),部署时复制pem+key即可。
|
||||
|
||||
### 部署步骤
|
||||
|
||||
> ✅ **推荐用一键脚本(在本地开发机执行)**:
|
||||
> ```bash
|
||||
> pwsh deploy/deploy-prod.ps1 # 完整部署:构建 → 校验产物 → 服务器备份 → 传 .new → 原子替换 → 重启 → 验收
|
||||
> pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,只重发现有产物
|
||||
> pwsh deploy/deploy-prod.ps1 -DryRun # 演练,不做任何改动
|
||||
> pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端
|
||||
> ```
|
||||
> 脚本会自动做:产物路径校验、**上传后 `chmod +x`**、**传 `.new` 再原子 `mv`**(避开 ETXTBSY)、
|
||||
> `nginx -t` 后才 reload、验收(服务/端口/域名/ERROR 数)、以及**用不存在的 IMEI 探测上报消费链路**
|
||||
> (不碰真实设备数据),最后打印回滚方法。
|
||||
>
|
||||
> ⛔ **`deploy/deploy-prod.sh` 与 `deploy/deploy-test.sh` 已废弃**(执行即报错退出):
|
||||
> 它们在 `target/release/` 找产物、会安装重复的 `pms-api-prod` 单元、并会用本地 `.env`
|
||||
> 覆盖线上 `.env`(历史上该文件带行内注释,会让生产起不来)。下面手写步骤仅供参考。
|
||||
|
||||
**1. 本地编译**
|
||||
```bash
|
||||
# 后端(Rust)— Windows 交叉编译 Linux 目标
|
||||
cd software/api-server
|
||||
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
cd software/device-server
|
||||
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
# 前端(React 管理端)
|
||||
cd software/web
|
||||
npm run build
|
||||
|
||||
# 微信小程序用户端(uni-app)
|
||||
cd software/h5-uniapp
|
||||
npm run build:mp-weixin
|
||||
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传
|
||||
```
|
||||
|
||||
**2. 传输到服务器**
|
||||
|
||||
> 产物名以 `Cargo.toml` 的 `[package] name` 为准:**api-server → `pms-api`**
|
||||
> (`software/api-server/Cargo.toml:2`)、**device-server → `pms-device-server`**
|
||||
> (`software/device-server/Cargo.toml:2`)。⛔ 旧文档写的 `device-server` 是**产物名写错**,
|
||||
> 在 `target/.../release/` 下根本没有这个文件。
|
||||
|
||||
```bash
|
||||
# 传输后端二进制(测试环境)
|
||||
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
|
||||
# ⚠️ 测试环境的落地文件名目前是 device-server(见 deploy/pms-device-test.service:11
|
||||
# 的 ExecStart=/data/pms_dev/device-server/device-server),与 Cargo.toml 的
|
||||
# pms-device-server 不一致 —— 这是既存事实,照它传,别改名(改名要同步改单元)
|
||||
scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms_dev/device-server/device-server
|
||||
|
||||
# 传输后端二进制(正式环境)—— 生产落地名就是 Cargo.toml 产物名
|
||||
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/
|
||||
scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms/device-server/
|
||||
|
||||
# 传输前端静态文件
|
||||
scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境
|
||||
scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境
|
||||
```
|
||||
|
||||
> 服务器实测(2026-09,`ls -la` + `ss -lntp`):
|
||||
> `/data/pms/api-server/pms-api`(:9000)、`/data/pms/device-server/pms-device-server`(:9001)、
|
||||
> `/data/pms_dev/api-server/pms-api`(:3000)、`/data/pms_dev/device-server/device-server`(:3001)。
|
||||
|
||||
#### 部署注意事项(最容易踩的坑,逐条都栽过)
|
||||
|
||||
1. **上传后必须 `chmod +x`** —— 否则 systemd 报 `203/EXEC`,服务起不来。
|
||||
2. **运行中的二进制不可直接覆盖**(Linux `ETXTBSY`)—— 正确姿势:
|
||||
传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。
|
||||
(`deploy-prod.ps1` 已内置这套流程。)
|
||||
3. **`.env` 不支持行内注释** —— `PORT=9000 # 注释` 会让配置解析失败、服务起不来;
|
||||
注释必须**独占一行**。(历史上用带行内注释的本地 `.env` 覆盖线上,导致生产起不来。)
|
||||
4. **`exports` 目录必须预置、且属主为 `pms`** —— api-server 以 `User=pms` **降权运行**
|
||||
(`deploy/pms-api-*.service` 的 `User=pms`/`Group=pms`),它**无法在 `/data/pms`(root 属主)
|
||||
下自建目录**;`exports`(异步导出落盘)缺失或属主不对 → 导出任务直接失败。
|
||||
实测正确属主:`drwxr-xr-x pms pms /data/pms/api-server/exports`(`uploads` 同理)。
|
||||
5. **`worker_shutdown_timeout 30s`**(`/etc/nginx/nginx.conf` 第 2 行)—— 调短会让正在传输的
|
||||
响应被中断;**改 nginx 配置前先 `nginx -t`**,通过后再 `systemctl reload nginx`。
|
||||
6. **stream 转发已移除(2026-09-20)—— 不要再给开发机开库端口** —— 主配置
|
||||
`/etc/nginx/nginx.conf` 里原来那个 `stream{}`(MySQL 3306 / Redis 6379 → 阿里云 RDS/Redis)
|
||||
**已整块删除**;服务器不再监听 3306/6379(`10.8.0.252:3306/6379` 实测已不可连)。
|
||||
⛔ **不要为了给开发机开库通路而在服务器上重新加 `stream{}` / socat / 任何端口转发** ——
|
||||
开发机要连库一律走 **SSH 隧道**(`pwsh tools/dev-tunnel.ps1`,见「本地开发环境」);
|
||||
服务侧(`/data/pms`、`/data/pms_dev`)本来就直连阿里云,也不需要转发层。
|
||||
**历史原因(务必记住)**:`stream{}` 全配置**只能有一个**、且只能在主配置里 ——
|
||||
历史上曾在 `conf.d/*.conf` 里又写了一个,nginx 以
|
||||
`"stream" directive is duplicate` **直接启动失败、整站不可用**(含生产域)。
|
||||
所以:conf.d 下只放 `http` 上下文的 `server {}`;**主配置里现在应当完全没有 `stream{}`**
|
||||
(若哪天又看到它,先查清是谁加的)。
|
||||
7. **单元必须带 `EnvironmentFile=`** —— 现行二进制强制要求环境变量,缺它起不来:
|
||||
`EnvironmentFile=/data/pms/api-server/.env` / `/data/pms/device-server/.env`(测试环境同理)。
|
||||
8. **端口/库号串了就是事故** —— 生产 `:9000/:9001` + db5/`pms`;测试 `:3000/:3001` + db6/`pms_dev`。
|
||||
部署后务必核对 `ss -lntp` 与域名返回码(见「部署架构」的判断方法)。
|
||||
9. **Redis 实时键结构变更后必须清理旧键** —— 见「踩过的坑」第 6 条(无 TTL 的旧键会被当成实时值)。
|
||||
10. **nginx 主配置也有仓库镜像,改线上前必须先比对** —— `deploy/nginx/nginx.conf`
|
||||
(**与线上 `/etc/nginx/nginx.conf` 逐字节一致**,纯 LF、100 行、3733 字节、
|
||||
md5 `82f660d8ebd3299c89edf55483277266` —— 2026-09-20 移除 `stream{}` + 订正 stream 注释后重新对齐)。
|
||||
`deploy/nginx/` 下三份分别是:
|
||||
- `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `worker_shutdown_timeout`;
|
||||
⚠️ 已无 `stream{}` —— 见第 6 条,别再加回去)
|
||||
- `app.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/app.anzhizhichong.com.conf`
|
||||
- `test.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/test.anzhizhichong.com.conf`
|
||||
|
||||
**为什么必须有主配置镜像**:`limit_req_zone` 定义在**主配置**的 `http{}` 里,而 `conf.d/*.conf` 的
|
||||
`location` 引用了这些 zone。**只照仓库的 conf.d 重建 nginx,zone 就没了 ⇒ `nginx -t` 直接失败、整站起不来。**
|
||||
(历史已踩过 `stream{}` 重复、行内注释等同类问题。)
|
||||
|
||||
```bash
|
||||
# 改线上前:先确认线上与仓库镜像一致(不一致就先搞清是谁改的)
|
||||
plink -batch -ssh root@118.31.119.206 -pw "<pwd>" "md5sum /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf"
|
||||
md5sum deploy/nginx/nginx.conf deploy/nginx/*.conf # 纯 LF,可直接比对
|
||||
# 逐行比对(推荐,能看出差在哪)
|
||||
diff <(plink -batch -ssh root@118.31.119.206 -pw "<pwd>" "cat /etc/nginx/nginx.conf") deploy/nginx/nginx.conf
|
||||
```
|
||||
改完线上后**把仓库镜像同步更新**(双向一致),否则下次比对全是噪声。
|
||||
11. **新增域名/新增路径要限流,按这个顺序落(三步缺一不可)**:
|
||||
1. **主配置 `http{}` 加 zone**(`deploy/nginx/nginx.conf` + 线上 `/etc/nginx/nginx.conf`):
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=<name>_zone:10m rate=<N>r/m;
|
||||
```
|
||||
⛔ zone 名要唯一;`limit_req_status 429;` 只需在主配置写一次(已在)。
|
||||
> 现有 zone(2026-09 实测 4 个):`auth_login_zone` 10r/m、`auth_challenge_zone` 20r/m、
|
||||
> `uploads_zone` 120r/m、**`scan_abstract_zone` 30r/m** —— 只挂在
|
||||
> `location /api/pms/cabinets/abstract/`(**免认证**的扫码查设备入口,无 token 可枚举 `10-%08d`,
|
||||
> 是真实攻击面)。
|
||||
> ⚠️ **`/api/cabinets/scan/{abstract_id}` 不在这个 zone 里**(前缀 `abstract/` 匹配不到 `scan/`)——
|
||||
> 它**需登录**、无 token 恒 `401`(与 id 是否存在无关),**不构成枚举信号,故无需限流**。
|
||||
> 实测:`scan` 同 IP 60 连发 = `401`×60 / `429`×0;`abstract` 同法 = `404`×23 / `429`×37。
|
||||
2. **对应的 `conf.d/*.conf` 里给那个 location 挂 `limit_req`**:
|
||||
```nginx
|
||||
location = /api/pms/<path> {
|
||||
limit_req zone=<name>_zone burst=N nodelay;
|
||||
proxy_pass http://127.0.0.1:<port>/api/<path>;
|
||||
...
|
||||
}
|
||||
```
|
||||
⛔ **`limit_req` 引用的 zone 必须在主配置里存在**,否则 `nginx -t` 报
|
||||
`unknown limit_req_zone "<name>_zone"` 而拒绝启动。
|
||||
3. **先 `nginx -t`,通过后才 reload**:`nginx -t && systemctl reload nginx`。
|
||||
⛔ 不要跳过 `-t` 直接 `restart` —— 配置错会让**整站**(含生产域)起不来。
|
||||
4. 把线上改动同步回 `deploy/nginx/` 的对应文件(见第 10 条),并在报告里注明。
|
||||
|
||||
> 💡 **经验(写限流文档时必守)**:写"某 zone 覆盖某某路径"时,**必须按 `location` 的前缀语义核对,
|
||||
> 并附实测 `429` 的路径清单** —— 不要按 zone 的名字推断它管谁
|
||||
> (`scan_abstract_zone` 这名字本身就极易让人以为它管 `scan`,而它的 `location` 只有
|
||||
> `/api/pms/cabinets/abstract/`;`scan/` 落在无 `limit_req` 的通用 `location /api/pms/`)。
|
||||
|
||||
**2.1 微信小程序上传**
|
||||
|
||||
小程序构建产物 `dist/build/mp-weixin/` 不上传到服务器,而是通过微信开发者工具上传:
|
||||
|
||||
1. 打开微信开发者工具
|
||||
2. 导入项目目录 `software/h5-uniapp/dist/build/mp-weixin`
|
||||
3. 填入 AppID(已配置在 `manifest.json`)
|
||||
4. 预览/真机调试 → 上传体验版/正式版
|
||||
|
||||
**3. 服务器重启服务**
|
||||
```bash
|
||||
# SSH到服务器
|
||||
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
|
||||
|
||||
# 正式环境
|
||||
systemctl restart pms-api
|
||||
systemctl restart pms-device
|
||||
|
||||
# 重启Nginx
|
||||
systemctl restart nginx
|
||||
```
|
||||
1. ⛔ **服务器上禁止编译 Rust**(2核/3.5G 会 OOM)—— 本地 `cargo zigbuild --release
|
||||
--target x86_64-unknown-linux-gnu` 交叉编译后传二进制。
|
||||
2. **产物名以 `Cargo.toml` 的 `[package] name` 为准**:api-server → **`pms-api`**、
|
||||
device-server → **`pms-device-server`**。
|
||||
⚠️ 但**测试环境的 device 落地文件名是 `device-server`**(`deploy/pms-device-test.service`
|
||||
的 `ExecStart=/data/pms_dev/device-server/device-server`)—— 既存事实,照它传,别改名。
|
||||
3. **上传后必须 `chmod +x`**(否则 systemd `203/EXEC`);**运行中的二进制不可直接覆盖**
|
||||
(Linux `ETXTBSY`)⇒ 传 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f` → `systemctl start`。
|
||||
(`deploy/deploy-prod.ps1` 已内置。)
|
||||
4. **`.env` 不支持行内注释** —— `PORT=9000 # 注释` 会让服务起不来,注释必须独占一行。
|
||||
5. **单元必须带 `EnvironmentFile=`** —— 现行二进制强制要求环境变量,缺它起不来。
|
||||
6. **`exports` / `uploads` 目录必须预置且属主为 `pms`** —— api-server 以 `User=pms`
|
||||
**降权运行**,无法在 `/data/pms`(root 属主)下自建目录。
|
||||
7. **端口/库号串了就是事故** —— 生产 `:9000/:9001` + db5/`pms`;测试 `:3000/:3001` + db6/`pms_dev`。
|
||||
部署后必须核对 `ss -lntp` 与域名返回码(`test` 域名 `/api/pms/` 应 **401**,502 = 测试后端没起)。
|
||||
8. **`stream{}` 已移除,不要再加回来** —— 服务器不再监听 3306/6379(实测确认);
|
||||
开发机要连库一律走 **SSH 隧道**(`tools/dev-tunnel.ps1`)。
|
||||
历史事故:`stream{}` 全配置**只能有一个**且只能在主配置,`conf.d` 里再写一个 ⇒
|
||||
`"stream" directive is duplicate` ⇒ **整站(含生产域)起不来**。
|
||||
9. **改 nginx 前先 `nginx -t`,通过后才 `systemctl reload nginx`**(不要直接 `restart`);
|
||||
主配置 `worker_shutdown_timeout` 调短会中断正在传输的响应。
|
||||
10. **nginx 有仓库镜像,改线上前先比 md5** —— `deploy/nginx/` 三份(`nginx.conf` 主配置 +
|
||||
`app`/`test` 两份 conf.d)**与线上逐字节一致**,当前 `nginx.conf` md5 =
|
||||
`82f660d8ebd3299c89edf55483277266`。改完线上**必须把仓库镜像同步更新**(双向一致),
|
||||
否则下次比对全是噪声。
|
||||
> 为什么必须有主配置镜像:`limit_req_zone` 定义在主配置 `http{}` 里,而 `conf.d` 的
|
||||
> `location` 引用这些 zone —— 只照仓库的 conf.d 重建 nginx,zone 就没了 ⇒
|
||||
> `nginx -t` 失败、整站起不来。
|
||||
11. **新增路径要限流就三步走**:① 主配置 `http{}` 加 `limit_req_zone`(名字要唯一);
|
||||
② `conf.d` 的对应 `location` 挂 `limit_req zone=...`(**引用的 zone 必须已在主配置存在**);
|
||||
③ `nginx -t` 通过后才 reload;④ 把线上改动同步回 `deploy/nginx/`。
|
||||
现有 4 个 zone:`auth_login_zone` 10r/m、`auth_challenge_zone` 20r/m、`uploads_zone` 120r/m、
|
||||
`scan_abstract_zone` 30r/m(**只挂 `/api/pms/cabinets/abstract/`**)。
|
||||
> 💡 写"某 zone 覆盖某某路径"时**必须按 `location` 前缀语义核对并附实测 429 路径**,
|
||||
> 不要按 zone 名字推断它管谁(`scan_abstract_zone` 这名字极易让人以为它管 `scan/`,
|
||||
> 而它只挂 `abstract/`;`scan/` 需登录、无 token 恒 401,不构成枚举信号故无限流)。
|
||||
12. **Redis 实时键结构变更后必须清理旧键** —— 见「踩过的坑」第 6 条。
|
||||
|
||||
### 服务管理
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
systemctl status pms-api # 正式环境API
|
||||
systemctl status pms-device # 正式环境TCP
|
||||
systemctl status nginx
|
||||
|
||||
# 查看日志
|
||||
journalctl -u pms-api -f
|
||||
journalctl -u pms-device -f
|
||||
systemctl status pms-api pms-device pms-api-test pms-device-test nginx
|
||||
journalctl -u pms-api -f # 生产 API
|
||||
journalctl -u pms-api-test -f # 测试 API
|
||||
tail -f /var/log/nginx/error.log
|
||||
```
|
||||
|
||||
> ⚠️ **发布顺序硬约束**:`device-server` 与 `api-server` **必须同批发布**(`device:authed:{imei}`
|
||||
> 是跨服务契约)。只发 api-server 会让**所有设备看起来离线**。顺序:device 先、api 后。
|
||||
|
||||
## 硬件架构
|
||||
|
||||
- 柜控板(ID128)×1 + 仓控板(ID1-N)×N,每仓控板管6个仓体
|
||||
@ -1224,6 +932,9 @@ tail -f /var/log/nginx/error.log
|
||||
|
||||
## 数据模型
|
||||
|
||||
> 📖 **逐列说明见 [docs/tech/数据模型.md](docs/tech/数据模型.md)**(由本节整段搬迁,内容逐字保留)。
|
||||
> **改任何表结构前必须读它**;本节只留改动前必须**先知道**的硬约束。
|
||||
|
||||
### 核心关系
|
||||
|
||||
```
|
||||
@ -1232,333 +943,87 @@ organization(组织/企业)
|
||||
│ └── cabinet(充电柜)
|
||||
│ ├── cabin_board(仓控板×N) board_id 1–15
|
||||
│ │ └── compartment(仓体×6) channel_id 1–8
|
||||
│ ├── charge_order(充电订单,进行中/已结算;active_flag 唯一键防并发)
|
||||
│ ├── charge_order(充电订单,进行中/已结算)
|
||||
│ ├── charge_record(充电记录,结算后留痕)
|
||||
│ ├── energy_stat(能耗统计,按 柜×日 唯一)
|
||||
│ └── device_log(设备日志)
|
||||
├── user(用户) ──┬── user_roles ── role ── role_permissions ── permission
|
||||
│ ├── user_scopes(数据范围,项目级生效)
|
||||
│ └── operation_log(操作日志) / user_feedback(问题反馈) / download_tasks(下载中心)
|
||||
│ └── operation_log / user_feedback / download_task
|
||||
└── abstract_binding(平台抽象ID 10-xxxxxxxx ↔ cabinet 一对一)
|
||||
|
||||
battery(电池台账,按 battery_id 唯一) ── sim_change_log(换 SIM 记录,按 imei)
|
||||
```
|
||||
|
||||
**外键与唯一键要点(改动前必读)**
|
||||
- `compartments` 的父是 `cabin_boards`,`cabin_boards` 的父是 `cabinets`,`cabinets` 的父是 `projects`;
|
||||
建表语句**顺序即依赖顺序**(`migrate.rs::SCHEMA_SQL`,被引用表必须先建,否则 MySQL 报 1824)。
|
||||
- `charge_orders` / `charge_records` 同时外键到 `compartments` 与 `cabinets`。
|
||||
- `energy_stats` 有 `UNIQUE(cabinet_id, stat_date)`;`abstract_bindings` / `batteries` / `cabinets.imei`(唯一) 同理。
|
||||
### 21 张表(实测两库表名完全一致,`information_schema.TABLES` 计数 = 21)
|
||||
|
||||
### 数据库表
|
||||
`organizations` `projects` `cabinets` `cabin_boards` `compartments` `batteries`
|
||||
`charge_orders` `charge_records` `energy_stats` `device_logs` `abstract_bindings`
|
||||
`users` `roles` `permissions` `user_roles` `role_permissions` `user_scopes`
|
||||
`operation_logs` `user_feedback` `download_tasks` `sim_change_logs`
|
||||
|
||||
**实测:测试库 `pms_dev` 与生产库 `pms` 各 21 张表**(依据:
|
||||
`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=...`,两库表名完全一致)。
|
||||
### 改动前必读的硬约束
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `organizations` | 组织/企业(`parent_id` 支持父组织,NULL=顶级) |
|
||||
| `projects` | 项目/放置地点(隶属组织) |
|
||||
| `cabinets` | 充电柜(`imei` 唯一、`abstract_id`、`auth_str`+`auth_str_issued`、`cell`、`work_mode`、`status`、经纬度、`address`) |
|
||||
| `cabin_boards` | 仓控板(`board_id` 1–15) |
|
||||
| `compartments` | 仓体/通道(`channel_id` 1–8;`status` **只表示占用** 0 空闲/1 充电中,充满与故障不落库) |
|
||||
| `batteries` | 电池台账(`battery_id` 唯一、SOC‰、厂家/型号/材料代码原值、额定电压容量、状态) |
|
||||
| `charge_orders` | 充电订单(进行中/已结算;`reason` normal/user_stop/forced/timeout;`active_flag` 唯一键防并发建单) |
|
||||
| `charge_records` | 充电记录(结算后留痕;`energy` 单位 **Wh**、`max_temperature` K×100) |
|
||||
| `device_logs` | 设备日志(`log_type` 1 状态上报 / 2 故障 / 3 告警 / 4 停电) |
|
||||
| `energy_stats` | 能耗统计(按 `cabinet_id` + `stat_date` 唯一,`total_energy` 单位 **Wh**) |
|
||||
| `operation_logs` | 操作日志(按 `user_id` 归属组织;**不叠加项目级数据范围**) |
|
||||
| `users` | 用户(`phone` 唯一、argon2 密码、`role` 0/1/2、`organization_id`) |
|
||||
| `roles` | 角色(内置 `super_admin` / `org_admin` / `normal_user`) |
|
||||
| `permissions` | 权限码字典(31 条,见 `migrate.rs::seed_permissions`) |
|
||||
| `role_permissions` | 角色↔权限 关联(复合主键) |
|
||||
| `user_roles` | 用户↔角色 关联(复合主键;**为空时按 `users.role` 回退内置角色权限**,见 `auth.rs::load_user_permissions`) |
|
||||
| `user_scopes` | 数据范围(`scope_type` 1 组织 / 2 项目 / 3 设备;**只有 1、2 参与鉴权**) |
|
||||
| `download_tasks` | 下载中心/异步导出任务(`task_type`、`status` 0排队~3失败、`file_path`) |
|
||||
| `abstract_bindings` | 平台抽象ID ↔ 柜子 绑定(`abstract_id` / `cabinet_id` 均唯一) |
|
||||
| `sim_change_logs` | SIM 卡(ICCID)变更记录(`login` 时检测到变更即写) |
|
||||
| `user_feedback` | 用户问题反馈(内容/联系方式/处理状态) |
|
||||
|
||||
> 表清单的**权威来源**是 `software/api-server/src/db/migrate.rs::SCHEMA_SQL`(代码建表 DDL)
|
||||
> 与库实测两处,两者必须一致;不一致用 `python tools/db_schema_diff.py --vs-ddl` 查。
|
||||
|
||||
### Redis 实时状态存储
|
||||
|
||||
> **权威依据:`software/api-server/src/workers/report_worker.rs` 的模块头注释 + `write_device_state` /
|
||||
> `write_channel_state` / `handle_pow_fail` / `save_channel_battery`(写入侧),
|
||||
> `workers/energy.rs::order_energy_key`(累加器),`device-server/src/tcp/server.rs::update_device_route`(路由)。**
|
||||
> 下表是**逐字段核对代码后的实际写入契约**,不是设计稿。
|
||||
|
||||
**层级结构(实测:测试库 db6 的真实 key/hash 与下表完全一致):**
|
||||
|
||||
```
|
||||
device:{imei} # 设备级 Hash(TTL 300 秒,写它的每一条路径都会 EXPIRE 300)
|
||||
├─ online = "1" # 在线标记(login 成功 / status_post 均会置)
|
||||
├─ rssi = "85" # 信号强度(协议口径 0–99 等级,非 dBm)
|
||||
├─ temp = "29800" # 柜温 K×100(status_post 的 cabinet.temp)
|
||||
├─ meter = "123456" # 电表读数(status_post 的 cabinet.meter)
|
||||
├─ cabinet_status1 = "01" # 柜级状态位 byte0(2 位 hex,**取不到不写**)
|
||||
├─ cabinet_status2 = "00" # 柜级状态位 byte1(2 位 hex,**取不到不写**)
|
||||
├─ pow_fail_dc = "0/1" # 直流停电(接触器分闸)
|
||||
├─ pow_fail_ac = "0/1" # 交流停电(柜控板自身断电)
|
||||
└─ node_id = "node-1" # 该设备 TCP 连接所在节点(device-server 写,用于多节点路由)
|
||||
|
||||
device:{imei}:board:{板号}:ch:{通道号} # 通道级 Hash(**唯一的状态载体,无 TTL**)
|
||||
├─ batt_status = "82" # byte0 电池状态(2 位小写 hex,位定义见协议 §3.8)
|
||||
├─ pow_status = "82" # byte1 电源状态(2 位小写 hex,与电池状态独立)
|
||||
├─ soc = "850" # SOC ‰(硬件 1% → ×10)
|
||||
├─ voltage = "54000" # 电压 mV(硬件 0.01V → ×10)
|
||||
├─ current = "8000" # 电流 mA(硬件 0.01A → ×10)
|
||||
└─ temp = "29800" # 电芯最高温 K×100(硬件 1K → ×100)
|
||||
|
||||
device:{imei}:board:{板号}:ch:{通道号}:bat # 通道电池台账(内部键,TTL 48h,bat_in 登记)
|
||||
├─ battery_id / soc / info / brand_code / model_code / material_code / cell_count
|
||||
└─ (rated_voltage / rated_capacity)
|
||||
|
||||
device:{imei}:board:{板号}:ch:{通道号}:order # 通道订单电量累加器(内部键,Lua 原子累加)
|
||||
├─ mwh / maxtemp / ts / nobat
|
||||
└─ (由 workers/energy.rs 维护,供结单取电量)
|
||||
```
|
||||
|
||||
> ⚠️ **关键事实(旧文档写错了,逐条纠正):**
|
||||
> 1. **板级 hash `device:{imei}:board:{板号}` 根本不存在** —— 现协议板级没有状态字节,
|
||||
> 写入侧**只写通道级 key**。`/api/cabinets/realtime-status` 响应里那个 `boards[].status_hex`
|
||||
> 是**接口现拼**的(各通道 `batt_status + pow_status` 按通道号升序拼接),**不是一个 Redis 键**。
|
||||
> 生产 Redis 里若还能搜到 `device:*:board:N` 这种 hash(内容只有 `status_hex`),那是**旧版本残留**。
|
||||
> 2. **通道号是 1 基(1–8),不是 0 基**;老数据里出现过 `...:ch:0`,也是旧版本残留。
|
||||
> 板号同样从 1 开始,量程 1–15。
|
||||
> 3. **通道级字段是 `batt_status` / `pow_status` / `soc` / `voltage` / `current` / `temp`**,
|
||||
> **没有** `on` / `full` / `fault` / `hex` 这四个字段(那是旧格式;`on/full/fault/hex` 是接口
|
||||
> 由 `batt_status`/`pow_status` **派生出来的响应字段**,不是存储字段)。
|
||||
> 4. **板号和通道号十进制不补零**(`board:1:ch:3`,不是 `board:01:ch:03`)。
|
||||
> 5. 设备级 key 的**字段可能是残缺的**(如只有 `online`+`rssi`):`cabinet_status1/2` 只在
|
||||
> `status_post` 带合法 `cabinet.status1/2` 时才写;**读不到就是"无数据",绝不落 `"00"`**。
|
||||
> 6. `device:{imei}` 的 **TTL 就是在线判据**(300 秒内无任意上行即判离线),
|
||||
> 所以**任何写 `device:{imei}` 的代码都必须跟一条 `EXPIRE 300`**。
|
||||
> 7. hex 编码格式**已定稿**(协议 §3.8 的位定义表),**不再有"待硬件团队确认"的悬置项**。
|
||||
|
||||
**写入时机:**
|
||||
|
||||
| 事件 | 写入内容 |
|
||||
|------|----------|
|
||||
| `login`(验签通过) | `device:{imei}`:`online=1` + `EXPIRE 300`;另写 `node_id`(device-server 侧) |
|
||||
| `status_post` | `device:{imei}`:`online/rssi/temp/meter`(+`cabinet_status1/2`) + `EXPIRE 300`;<br>逐板逐通道 `device:{imei}:board:{b}:ch:{c}` 的 6 个字段;<br>`cabinet.status1` bit0=0 时**顺带把 `pow_fail_dc`/`pow_fail_ac` 清 0**(供电恢复);<br>并累加 `...:order` 的电量 |
|
||||
| `pow_fail_dc` | `device:{imei}.pow_fail_dc = 1` |
|
||||
| `pow_fail_ac` | `device:{imei}.pow_fail_ac = 1` |
|
||||
| `bat_in` | `device:{imei}:board:{b}:ch:{c}:bat`(电池台账,TTL 48h) |
|
||||
| 任意上行报文 | device-server 刷新 `device:{imei}` 的 TTL/`node_id`(路由用) |
|
||||
|
||||
> ⛔ **没有 `pow_fail` 这条 act**:停电是**两条独立指令** `pow_fail_dc`(直流,接触器分闸→仓控板下电)
|
||||
> 与 `pow_fail_ac`(交流,柜控板自身断电),各写**同名字段**(协议 §5.4 / §10 第 11 条)。
|
||||
> `report_worker.rs:237` 的 `"pow_fail_dc" | "pow_fail_ac" => handle_pow_fail(...)` 是唯一入口。
|
||||
|
||||
**通道状态位(`batt_status` / `pow_status`)—— 按协议 §3.8 位定义,不按"五态枚举":**
|
||||
|
||||
`batt_status`(byte0):bit0 充满|bit1 正在充电|bit2–3 保留(不展示)|bit4 过欠压|bit5 过流|
|
||||
bit6 热失控|**bit7 有电池**(决定"空仓/在位")。
|
||||
`pow_status`(byte1):bit0 电源故障|bit1–2 电源状态位|bit3–6 保留(不展示)|bit7 电源有效。
|
||||
|
||||
**全仓唯一权威推导规则**(`routes/cabinet_tree.rs::derive_channel_flags`,前端
|
||||
`web/src/utils/deviceStatus.ts` 与 `h5-uniapp/src/utils/deviceStatus.ts` 同源):
|
||||
|
||||
```
|
||||
展示状态 = fault > full > charging > idle
|
||||
fault = batt_status bit4|bit5|bit6 或 pow_status bit0
|
||||
full = batt_status bit0
|
||||
charging = batt_status bit1
|
||||
无数据(batt_status / pow_status 都取不到)= has_data:false
|
||||
→ 列表计数按 0 计、详情保留 DB 占用值,**绝不猜测**
|
||||
```
|
||||
|
||||
> 旧文档那张"空闲/插入/充电中/充满/故障"五态表**已废弃**:它既不是协议口径,
|
||||
> 也无法表达"电池在位但未充电"(`batt_status` bit7=1、bit0/bit1=0)这类真实状态。
|
||||
|
||||
**API 接口(接口总表以 [docs/API.md](docs/API.md) 为准,这里只列与 Redis 存储强相关的两个):**
|
||||
|
||||
| 接口 | 说明 | Redis 查询方式 |
|
||||
|------|------|----------------|
|
||||
| `GET /api/cabinets/filter` | 设备列表(设备级状态 + 充满/故障计数) | ① Pipeline `HGET` 每柜 6 个设备级字段(`online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2`,1 次往返);② 通道计数:**按 DB 的板号/通道号拼 key**(`fetch_channel_keys`,SQL 取拓扑)+ pipeline `HMGET(batt_status, pow_status)` —— **零 KEYS 扫描** |
|
||||
| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | `HMGET` 取设备级 4 字段;板/通道:**按 DB 拓扑(`cabinets→cabin_boards→compartments`)拼候选 key + 一次 pipeline `HGETALL`**,空 map = 键不存在(等价于只返回存在的键)。<br>✅ **2026-09 已修**:旧实现用 `KEYS device:{imei}:board:*:ch:*`,在**与既有充电站平台共用**的 Redis 实例上会阻塞单线程(实测每次请求 `cmdstat_keys` +1),已彻底移除 |
|
||||
|
||||
> ⛔ **本项目禁止在任何请求路径上用 `KEYS`** —— Redis 实例是共用的,`KEYS` 会阻塞单线程遍历整个
|
||||
> keyspace。要枚举就用「按 DB 拓扑拼 key」或 `SCAN` 游标。查是否有人用了:
|
||||
> `grep -rn '"KEYS"' software/*/src/`。
|
||||
1. ⛔ **建表语句的顺序即依赖顺序**(`migrate.rs::SCHEMA_SQL`)——
|
||||
`compartments` 的父是 `cabin_boards`、`cabin_boards` 的父是 `cabinets`、`cabinets` 的父是 `projects`。
|
||||
**被引用表必须先建**,否则新库初始化报 MySQL **1824**(外键找不到被引用表),
|
||||
叠加 `Restart=always` = **无限重启**。
|
||||
2. ⛔ **改 `SCHEMA_SQL` 的列定义时必须同步 `migrate.rs::LEGACY_COLUMN_TARGETS`**(有单测守门)——
|
||||
`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改列定义**,只改一处 = 新库与老库再次分叉。
|
||||
3. ⛔ **所有时间列必须是 `DATETIME`** —— 本仓 Rust 侧全是 `NaiveDateTime`,与 `TIMESTAMP` 不兼容,
|
||||
一碰就 500(详见「踩过的坑」第 3 条)。
|
||||
4. **`cabinets.status` 是启用/禁用配置态**(`2` = 禁用 ⇒ 拦截指令链路;其余 = 启用),
|
||||
**不表示在线**;在线判据来自 Redis(见「安全规范」的登录门控一节)。
|
||||
5. **`compartments.status` 只表示占用**(`0` 空闲 / `1` 充电中);"充满 / 故障"**不落库**,
|
||||
由实时数据派生。**`charge_orders` 只有 `0`/`1`**;**`charge_records.status` 分 3 档**。
|
||||
6. **唯一键**:`energy_stats` 有 `UNIQUE(cabinet_id, stat_date)`;
|
||||
`abstract_bindings.cabinet_id` / `batteries.battery_id` / `cabinets.imei` 唯一;
|
||||
`charge_orders` 用生成列 `active_flag` + 唯一索引防**同仓体并发建单**。
|
||||
|
||||
## TCP通讯协议
|
||||
|
||||
详见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
|
||||
详见 **[docs/protocol/柜控板固件对接文档.md](docs/protocol/柜控板固件对接文档.md)**(v1.1,**唯一权威 TCP 文档**;既是我们的内部文档,也可直接对外交付)— 包含帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时)、指令清单、签名算法与自测向量、安全码自愈流程、带注释报文示例。
|
||||
|
||||
> 📌 **文档口径优先级(避免"两份文档谁对"的扯皮)**:
|
||||
> 1. **`docs/柜控板固件对接文档.md`** —— 设备协议(帧格式/`act`/字段/签名)的**唯一权威**;
|
||||
> 1. **`docs/protocol/柜控板固件对接文档.md`** —— 设备协议(帧格式/`act`/字段/签名)的**唯一权威**;
|
||||
> 2. **本文件(AGENTS.md)** —— **平台侧实现口径**的权威:Redis 键/字段、接口行为、库结构、
|
||||
> 验收流程、部署注意事项。凡与 `docs/API.md` 的**平台存储/行为描述**不一致时,**以本文件为准**
|
||||
> 验收流程、部署注意事项。凡与 `docs/tech/API.md` 的**平台存储/行为描述**不一致时,**以本文件为准**
|
||||
> (本文件的每一条都按代码契约与库实测核对过);
|
||||
> 3. **`docs/API.md`** —— **HTTP 接口清单与响应结构**的权威(含数据单位规范);
|
||||
> 3. **`docs/tech/API.md`** —— **HTTP 接口清单与响应结构**的权威(含数据单位规范);
|
||||
> 4. 代码与文档冲突时:**先确认是不是代码错**;确认代码对就改文档并在报告里点出。
|
||||
|
||||
## 数据单位规范
|
||||
|
||||
详见 [docs/API.md](docs/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。
|
||||
详见 [docs/tech/API.md](docs/tech/API.md#数据单位规范) — 所有数值字段使用整数存储,禁止浮点类型。
|
||||
|
||||
> ✅ 浮点问题已清理完毕:所有业务字段使用 i64/BIGINT,SQL 查询使用 CAST(... AS SIGNED)。仅 Excel 导出的 `write_number` API 参数为 f64(`i64 as f64` 转换,非业务浮点)。
|
||||
|
||||
## 微信小程序用户端
|
||||
|
||||
### 小程序配置
|
||||
|
||||
| 参数 | 值 |
|
||||
|------|-----|
|
||||
| AppID | `wx226061c550c52e85` |
|
||||
| AppSecret | `f11dfee9dc73265b75bf54277ebb1047` |
|
||||
| 账号 | `13951516272@163.com` |
|
||||
| 消息加解密方式 | 明文模式 |
|
||||
| 数据格式 | XML |
|
||||
| 服务器地址(URL) | `https://app.anzhizhichong.com/api/wechat/callback` |
|
||||
| Token(令牌) | `7GW16XdnFyak0mE32rc8ZPij` |
|
||||
| EncodingAESKey | `kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu` |
|
||||
|
||||
> ⚠️ 以上密钥仅记录在内网 Git 仓库 `生产环境.txt`,不入代码仓库。
|
||||
|
||||
### 构建命令
|
||||
> 📖 **完整篇见 [docs/tech/小程序用户端.md](docs/tech/小程序用户端.md)**(由本节整段搬迁,内容逐字保留)——
|
||||
> 页面清单、逐页职责、状态位分层展示口径、RSSI 分档都在那里。本节只留在改动前必须知道的硬约束。
|
||||
|
||||
- **技术栈**:uni-app + Vue 3 + Pinia,构建目标 `mp-weixin`(`software/h5-uniapp`)。
|
||||
- **产物不上传服务器**:`npm run build:mp-weixin` 产出 `dist/build/mp-weixin/`,
|
||||
用**微信开发者工具**导入后上传体验版/正式版(服务器上没有小程序产物)。
|
||||
- **两道门禁(缺一不可)**:
|
||||
1. `npm run typecheck`(= `vue-tsc --noEmit`,**不是 `tsc`** —— tsc 不检查 `.vue`;
|
||||
详见「踩过的坑」第 2 条);
|
||||
2. `npm run build:mp-weixin`(只验语法,不验类型)。
|
||||
- ⛔ **跨端口径必须三处一致**(PC `web/.../cabinet.tsx`、小程序 `device-detail/index.vue`、
|
||||
`compartment-detail/index.vue`):**状态位解析**与 **RSSI 分档**已有逐值断言守门,改一处必须跑:
|
||||
```bash
|
||||
cd software/h5-uniapp
|
||||
npm run build:mp-weixin
|
||||
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传
|
||||
node test/device_status_parse.test.mjs # 逐位遍历,断言 PC 与小程序口径一致
|
||||
node test/rssi_signal.test.mjs # 遍历 0–99,断言 level/bars/color 相同
|
||||
```
|
||||
|
||||
### API 域名配置
|
||||
|
||||
小程序请求后端 API 必须配置合法域名(HTTPS):
|
||||
|
||||
- 后台「开发管理 → 开发设置 → 服务器域名」
|
||||
- request 合法域名:`https://app.anzhizhichong.com`
|
||||
- 开发阶段可在 `manifest.json` 设 `urlCheck: false` 跳过校验
|
||||
|
||||
### API base URL
|
||||
|
||||
小程序用全 URL(`api/index.ts` 条件编译):
|
||||
|
||||
```typescript
|
||||
// #ifdef MP-WEIXIN
|
||||
const BASE_URL = 'https://app.anzhizhichong.com/api/pms'
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
const BASE_URL = '/api/pms'
|
||||
// #endif
|
||||
```
|
||||
|
||||
### 信号强度显示(RSSI)
|
||||
|
||||
设备卡片右上角显示 RSSI 信号强度。
|
||||
|
||||
> ✅ **2026-09 已收敛为「两端同源同名、统一 5 档」**(此前两端不一致,见下方"行为变化")。
|
||||
> **唯一权威实现**:`web/src/utils/deviceStatus.ts` 与 `h5-uniapp/src/utils/deviceStatus.ts`
|
||||
> 的 `rssiSignal()` / `rssiBarColors()` / `RSSI_TIERS`(**两端逐字一致,改一边必须同步另一边**)。
|
||||
> 一致性用例:`node test/rssi_signal.test.mjs`(遍历 0–99 逐值断言两端 `level`/`bars`/`color` 相同)。
|
||||
> ⛔ **页面里不得再自己写一套阈值**(历史上两端各写一套,就是不一致的根因)。
|
||||
|
||||
**统一分档表(协议口径:`rssi` 是 0–99 的信号等级,不是 dBm)**
|
||||
|
||||
| 阈值 | `level` | 颜色 | 5 格制格数 | PC 图标亮的 `<path>` |
|
||||
|------|---------|------|-----------|---------------------|
|
||||
| ≥ 70 | 4 `strong` | `#16a34a` 绿 | 5 | 4 条全亮 |
|
||||
| ≥ 55 | 3 `good` | `#2563eb` 蓝 | 4 | 3 条 |
|
||||
| ≥ 40 | 2 `fair` | `#f59e0b` 黄 | 3 | 2 条 |
|
||||
| ≥ 30 | 1 `weak` | `#f97316` 橙 | 2 | 1 条 |
|
||||
| < 30 或 null | 0 `none` | `#9ca3af` 灰 | 1(底格) | 0 条(只剩底部圆点 `M2 20h.01`) |
|
||||
|
||||
**两端如何消费同一份纯函数:**
|
||||
- **PC**(`web/src/pages/devices/CabinetGrid.tsx::SignalIconComponent`):`rssiSignal(rssi)` 取 `color` 与 `level`,
|
||||
4 条 `<path>` 按 `level > i` 决定亮几条;卡片头部数值颜色用同一个 `color`。
|
||||
- **小程序**(`home/index.vue`、`device-detail/index.vue`):
|
||||
`v-for="(color, i) in rssiBarColors(cab.rssi)"` 渲染 `bars` 根 `<view class="signal-bar">`,
|
||||
颜色**内联** `:style="{ background: color }"`;**格数真的随档位变化**。
|
||||
- 颜色只在 TS 里定义一次(不再有 `.signal.good/.mid/.low/.off` 这类 CSS 颜色规则;
|
||||
页面 CSS 只负责尺寸/间距)。
|
||||
|
||||
**后端(两端共用):** 设备列表接口用 pipeline 批量 `HGET device:{imei} rssi`
|
||||
(`routes/cabinet_tree.rs::attach_realtime_status`,字段下标见 `DEVICE_LIST_FIELDS`);
|
||||
小程序侧 `routes/h5.rs::get_cabinets` 同样带 Redis。
|
||||
|
||||
**相关文件:**
|
||||
- 纯函数(权威): `software/web/src/utils/deviceStatus.ts`、`software/h5-uniapp/src/utils/deviceStatus.ts`
|
||||
- PC 消费点: `software/web/src/pages/devices/CabinetGrid.tsx`(`SignalIconComponent` + 卡片头部)
|
||||
- 小程序消费点: `software/h5-uniapp/src/pages/home/index.vue`、`pages/device-detail/index.vue`
|
||||
- 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi`
|
||||
- 一致性用例: `test/rssi_signal.test.mjs`
|
||||
|
||||
> ⚠️ **行为变化(2026-09,小程序端显示会变 —— 这是有意为之)**
|
||||
> 收敛前:PC 5 档(≥70 绿/≥55 蓝/≥40 黄/≥30 橙/<30 灰,格数随档位增删);
|
||||
> 小程序只有 **4 档(无 ≥70)**、**30–39 用红色**、且**恒渲染 5 格只改颜色**。
|
||||
> 收敛后小程序:**新增 ≥70 档(绿/满格)**、**30–39 由红改橙**、**≥55 由绿改蓝**、
|
||||
> **离线灰由 `#94a3b8` 改为 `#9ca3af`**,且**格数随档位变化**(不再恒 5 格)。
|
||||
> 影响页面:小程序首页设备卡片、小程序设备详情页头部。
|
||||
|
||||
### 近期 UI 改动
|
||||
|
||||
1. **设备卡片头部**:**PC 管理端**(`web/src/pages/devices/CabinetGrid.tsx`)去掉"在线/离线"文字 badge,
|
||||
改为信号图标 + RSSI 数值。
|
||||
✅ **小程序保留"在线/离线"文字 badge —— 这是有意差异,不是缺陷,不要当 bug 改掉**:
|
||||
`h5-uniapp/src/pages/home/index.vue:43`(设备卡片,`cab.status===1 ? '在线' : '离线'`)与
|
||||
`pages/device-detail/index.vue:18`(设备详情;同文件 `:74` 还有**板级**的在线/离线 badge)。
|
||||
理由(产品决定):PC 用"信号图标 + RSSI 数值"表达;小程序屏幕小、文字 badge 更直观。
|
||||
> PC 卡片:**图标 + 数值**;小程序:**文字 badge**(有意保留)。
|
||||
另外 **PC 设备详情页**(`web/src/pages/devices/cabinet.tsx:503`)也仍用 `Tag` 渲染
|
||||
`CABINET_STATUS_MAP`(`0 离线 / 1 在线 / 2 禁用`)—— 只是**卡片**去掉了,别理解成"全平台都去掉了"。
|
||||
2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中)
|
||||
3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
|
||||
|
||||
### 状态位展示约定(**状态位按归属层级展示**)
|
||||
|
||||
> 以下三条是**协议 §3.8 / §4.2 / §5.7 的既定结论**(产品已确认),后续不要再反复确认。
|
||||
|
||||
1. **柜级状态位 → 显示在"柜子"层面**
|
||||
- 数据:**⚠️ 这两个名字不是 DB 列**(实测 `cabinets` 表**没有** `status1`/`status2` 列,
|
||||
它的列是 `id/project_id/abstract_id/imei/iccid/auth_str/name/address/status/created_at/updated_at/
|
||||
latitude/longitude/work_mode/auth_str_issued/cell`)。它们是:
|
||||
**Redis 字段** `device:{imei}` 的 `cabinet_status1` / `cabinet_status2`(设备上报原样落,2 位 hex)
|
||||
→ 接口**原样透出**为响应字段 `status1` / `status2`。
|
||||
写:`report_worker.rs::write_device_state`;读:`cabinet_tree.rs::attach_realtime_status`(列表)、
|
||||
`cabinets.rs::fetch_cabinet_realtime`(详情)。
|
||||
- 位定义(§3.8):`status1` bit0 断电|bit1 烟雾|bit2 水位|bit3 倾倒|**bit4 柜门打开**|
|
||||
bit5 加热风扇|bit6 接触器断开|bit7 喷淋;`status2` bit0 声光报警|**bit1 柜门打开超时**
|
||||
(阈值硬件定义,**平台只呈现、不自行判定**);`status2` bit2–7 为保留位,**不展示**。
|
||||
- 页面:PC `web/src/pages/devices/cabinet.tsx`、小程序 `h5-uniapp/src/pages/device-detail/index.vue`。
|
||||
- **取不到返回 `null`(未知),绝不落 `"00"`** —— `"00"` 是"全部正常"的有效值。
|
||||
2. **仓体级状态位 → 显示在"仓体"层面**
|
||||
- 数据:通道 18 字符 hex 的 `batt_status`(byte0) / `pow_status`(byte1);保留位
|
||||
(`batt_status` bit2–3、`pow_status` bit3–6)**不展示**。
|
||||
- 页面:PC 柜子详情里的通道卡片、小程序 `pages/compartment-detail/index.vue`。
|
||||
- **仓体层面不显示任何"门"状态**:协议**没有**"按仓体(舱门)开门"的指令(§4.2 L403)。
|
||||
3. **柜门是柜级独立动作,不与业务联动,全平台只有一个开门按钮**
|
||||
- `open` 是**柜级**指令(**不带 `id`**),语义是**「柜门解锁,下发后由人拉开,门不会自动开」**;
|
||||
没有"关门"指令(关门也是人为动作)。**全平台只有一扇柜门**,不按仓体区分。
|
||||
- **柜门状态永远来自 `status_post`(约 60 秒一次),不是实时量** —— 界面上必须标注
|
||||
"来自最近一次上报(约 60 秒一次),非实时"(§4.2 L428)。
|
||||
- 开门是**独立流程**,与电池插入、充电开始/结束**没有因果关系也没有先后要求**(§5.7、§7.2):
|
||||
不得出现在"开始充电"流程里、不得改任何订单状态、不得要求存在订单。
|
||||
- 入口只有一个:PC 详情页按钮(`<Permission code="charge:open_door">` 包裹)+
|
||||
小程序设备详情页按钮;**仓体(通道)详情页不放开门按钮**。
|
||||
- 权限码 `charge:open_door`(后端 `check_permission` 硬校验;`device:door:unlock` 是历史误用的
|
||||
不存在权限码,**禁止再用**)。
|
||||
4. **解析函数两端必须同源同名**:`web/src/utils/deviceStatus.ts` 与
|
||||
`h5-uniapp/src/utils/deviceStatus.ts` 各有一组 `parseCabinetStatus1` / `parseCabinetStatus2` /
|
||||
`cabinetDoorState` / `cabinetStatusTags`(注释互相指认对方文件,改一边务必同步另一边)。
|
||||
端到端一致性用例见 `test/device_status_parse.test.mjs`(`node test/device_status_parse.test.mjs`,
|
||||
逐位遍历 0x00–0xFF 断言两端一致)。
|
||||
5. **`cabinets.status` 是「启用/禁用」配置态,不表示在线** —— `1` 启用 / `2` 禁用(`0` 是建表默认值
|
||||
产生的取值,代码不显式写它,语义等同启用);实时在线一律以 Redis `device:{imei}.online` 为准
|
||||
(唯一权威判据 `routes/cabinet_tree.rs::device_online_flag`,接口字段 `is_online`),
|
||||
`energy_stats` 的 `online_count`、H5 首页 `online_cabinets`、PC/小程序的「在线/离线」全部消费它
|
||||
(`CABINET_STATUS_MAP` 只保留启用/禁用两档,**不要再出现"离线"档**)。
|
||||
|
||||
### 开发约束(uni-app 特定)
|
||||
|
||||
1. **`<text>` 不支持 `display: flex`** — uni-app 的 `<text>` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `<view>`
|
||||
2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠
|
||||
3. **内联 SVG + v-if**:Vue 3 支持在 `<path>` 上用 `v-if` 条件渲染,适用于动态图标
|
||||
4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `<svg>` 上写死
|
||||
- **状态徽标的两个维度严格分离**:`cabinets.status` = 启用/禁用**配置态**(`2` = 禁用);
|
||||
**在线/离线只看接口返回的 `is_online`**(Redis 实时值)。
|
||||
⛔ **不得用 `status` 推断在线**(历史 BUG:`status` 曾被当成在线依据,在一台设备都没连时
|
||||
仍显示"在线")。
|
||||
- ⛔ **不显示推不出来的状态**:`cabin_boards.status` **全后端没有任何写入方**,
|
||||
板级"在线/离线"在协议层面也不存在(TCP 长连接的是柜控板)—— 该徽标已删除,
|
||||
**不要再加回来**(见 `src/api/index.ts` 的 `CabinBoard` 注释)。
|
||||
- **温度口径**:K×100 转摄氏 `(temp / 100) - 273.15`,与 Web 管理端一致(`utils/deviceStatus.ts`)。
|
||||
|
||||
## 代码审查豁免
|
||||
|
||||
|
||||
@ -121,8 +121,8 @@ charging-cabinet/
|
||||
|
||||
## TCP 设备通讯协议
|
||||
|
||||
> TCP 设备通讯协议见 **[docs/柜控板固件对接文档.md](docs/柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。
|
||||
> 全局整数单位(voltage mV、current mA、temp K×100、soc ‰)见 [docs/API.md](docs/API.md#数据单位规范)。
|
||||
> TCP 设备通讯协议见 **[docs/protocol/柜控板固件对接文档.md](docs/protocol/柜控板固件对接文档.md)**(唯一权威,v1.1)。本文不再重复。
|
||||
> 全局整数单位(voltage mV、current mA、temp K×100、soc ‰)见 [docs/tech/API.md](docs/tech/API.md#数据单位规范)。
|
||||
> 平台侧 Redis 实时状态的实际存储结构(key/字段/写入时机)见 [AGENTS.md](AGENTS.md#redis-实时状态存储)。
|
||||
|
||||
## 开发任务与质量约束
|
||||
|
||||
107
docs/README.md
Normal file
107
docs/README.md
Normal file
@ -0,0 +1,107 @@
|
||||
# docs/ 文档地图
|
||||
|
||||
> **本文件是 `docs/` 的分类索引。** 新增、移动或改名任何文档后,**必须回来更新本文件**
|
||||
> 并同步全仓引用(见文末「维护约定」)。
|
||||
|
||||
## 一、分类总览
|
||||
|
||||
| 目录 | 定位 | 权威性 | 谁该读 |
|
||||
|------|------|--------|--------|
|
||||
| [`protocol/`](#protocol设备协议) | **设备协议**(冻结,对硬件工程师交付) | ⭐ **唯一权威** | 硬件工程师、固件、平台 TCP 相关开发 |
|
||||
| [`tech/`](#tech平台技术文档) | **平台技术文档**(开发/运维必读) | ⭐ 权威(各自领域内) | 后端/前端/运维 |
|
||||
| [`contract/`](#contract合同与规格) | **合同、规格、验收标准**(商务依据) | 事实依据,但不描述实现 | PM、验收方、新人了解范围 |
|
||||
| [`compound-notes/`](#compound-notes经验沉淀) | **单点经验笔记**(踩坑/技法) | 参考,非权威 | 遇到同类问题的人 |
|
||||
| [`archive/`](#archive历史归档) | **历史留痕**(只读,**不代表现状**) | ⛔ **不可作为依据** | 追溯历史决策时 |
|
||||
|
||||
## 二、谁是权威(口径优先级,避免"两份文档谁对"的扯皮)
|
||||
|
||||
1. **`protocol/柜控板固件对接文档.md`** —— 设备协议(帧格式 / `act` / 字段名 / 签名 / `id` 规则)
|
||||
的**唯一权威**。协议**冻结**,设备端固件不可改。
|
||||
2. **根目录 `AGENTS.md`** —— **平台侧实现口径**的权威:Redis 键与字段、接口行为、库结构、
|
||||
验收流程、部署注意事项。与 `tech/API.md` 的平台存储/行为描述冲突时,**以 `AGENTS.md` 为准**
|
||||
(它的每一条都按代码契约与库实测核对过)。
|
||||
3. **`tech/API.md`** —— **HTTP 接口清单与响应结构**的权威(含数据单位规范)。
|
||||
4. **代码与文档冲突时**:先确认是不是代码错;确认代码对就**改文档**并在报告里点出。
|
||||
|
||||
## 三、逐目录说明
|
||||
|
||||
### protocol/ —— 设备协议
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `柜控板固件对接文档.md` | **唯一权威 TCP 文档**(v1.1.1)。帧格式、通用规则(`act`/`suc`/`msg_id`/60 秒超时/登录门控)、指令清单、签名算法与自测向量、安全码自愈、带注释报文示例(附 A)、变更记录(附 B)。**既是内部文档也可直接对外交付。** |
|
||||
| `1TN协议标准_YG20240118-1_V1.0.md` | 仓控板 ↔ 电源(苏州翌工)1TN RS485 协议文本版 |
|
||||
| `1TN协议标准_苏州翌工电源_V1.0.pdf` | 同上,厂家原始 PDF |
|
||||
|
||||
### tech/ —— 平台技术文档
|
||||
|
||||
| 文件 | 说明 | 入口 |
|
||||
|------|------|------|
|
||||
| `API.md` | HTTP 接口清单与响应结构 + **数据单位规范**(整数存储,禁浮点) | PC/小程序前端、后端 |
|
||||
| `数据模型.md` | 21 张表的逐列语义、外键/唯一键、建表依赖顺序 | **改表结构前必读** |
|
||||
| `部署手册.md` | 完整部署步骤、nginx 两份 conf.d 全文、**全部 11 条部署注意事项** | 运维、发版 |
|
||||
| `小程序用户端.md` | 小程序页面清单、逐页职责、状态位分层展示、RSSI 分档口径 | 小程序开发 |
|
||||
|
||||
> 这四份是 `AGENTS.md` 的**详细参考篇**:`AGENTS.md` 只保留"判据 + 链接"
|
||||
> (因为 `AGENTS.md` 超出工作区指令预算 65,536 B 时会被**按头部截断**,尾部根本不加载)。
|
||||
|
||||
### contract/ —— 合同与规格
|
||||
|
||||
`开发合同` / `附件一-硬件规格` / `附件二-软件规格` / `附件三-交付与验收标准` /
|
||||
`附件-开发需求规格说明书`,各含 `.md` + 原始格式(`.docx` / `.pdf` / `.html`)双份。
|
||||
|
||||
> 用途:**范围与验收的事实依据**。描述"要做什么 / 达到什么标准",**不描述当前实现**
|
||||
> ——实现现状一律以 `AGENTS.md` + 代码为准。
|
||||
|
||||
### compound-notes/ —— 经验沉淀
|
||||
|
||||
单点问题的笔记(如某次 SQL 注入的成因与修法)。**参考性**内容,
|
||||
遇到同类问题时查;结论若上升为通用规范,应回写进 `AGENTS.md` 的「踩过的坑」。
|
||||
|
||||
### archive/ —— 历史归档
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `开发文档.md` | 早期(现行实现之前)的开发文档,**与当前实现多处不一致** |
|
||||
| `审查报告-数据单位一致性-2026-07-06.md` | 2026-07-06 的数据单位审查报告 |
|
||||
| `项目深度分析报告-2026-07-06.md` | 2026-07-06 的项目深度分析报告 |
|
||||
|
||||
⛔ **归档目录只读、不作依据**——里面的路径、结论、数字都是**当时**的。
|
||||
其中出现的旧文档名(如 `tcp-protocol.md`、`docs/柜控板-平台TCP接口文档.md`)
|
||||
是历史事实,**不要求可点击**;对应关系到当前文档的映射见下节。
|
||||
|
||||
## 四、旧名 → 现名映射(查旧链接时用)
|
||||
|
||||
| 旧路径(已不存在) | 现路径 |
|
||||
|---|---|
|
||||
| `docs/tcp-protocol.md` | `docs/protocol/柜控板固件对接文档.md` |
|
||||
| `docs/柜控板-平台TCP接口文档.md` | 已于协议 v1.1 删除(内部重复文档,内容并入上者) |
|
||||
| `docs/柜控板固件对接文档.md` | `docs/protocol/柜控板固件对接文档.md` |
|
||||
| `docs/API.md` | `docs/tech/API.md` |
|
||||
| `docs/数据模型.md` · `docs/部署手册.md` · `docs/小程序用户端.md` | 同名,移到 `docs/tech/` |
|
||||
| `docs/开发文档.md` | `docs/archive/开发文档.md` |
|
||||
| 其余合同/规格/验收/协议标准 | 见上表,分别归入 `contract/` 与 `protocol/` |
|
||||
|
||||
## 五、维护约定(新增/移动文档时必做)
|
||||
|
||||
1. **归位**:按"协议 / 技术 / 合同 / 经验 / 归档"分类放入对应目录,**不要再平铺在 `docs/` 根**。
|
||||
2. **更新本索引**:在对应章节的表格里加一行(文件 + 一句话定位 + 谁该读)。
|
||||
3. **同步全仓引用**:文档路径散落在 `AGENTS.md`、`README.md`、**Rust/TS/Vue 源码注释**、
|
||||
`test/`、`tools/`、`tasks/` 里;移动后必须全仓替换。
|
||||
校验方法(**应无输出**):
|
||||
```bash
|
||||
# 找仍指向旧平铺路径的活引用
|
||||
# · 排除 docs/archive/ —— 归档按设计保留历史原文
|
||||
# · 排除 docs/README.md —— 本文件的「旧名 → 现名映射」表**必须**出现旧名
|
||||
grep -rn --exclude-dir={.git,node_modules,target,dist} \
|
||||
-e 'docs/API\.md' -e 'docs/数据模型\.md' -e 'docs/部署手册\.md' \
|
||||
-e 'docs/小程序用户端\.md' -e 'docs/开发文档\.md' . \
|
||||
| grep -v '^\./docs/archive/' | grep -v '^\./docs/README\.md:'
|
||||
```
|
||||
> 已实测该命令**能抓到问题**:往 `docs/tech/API.md` 末尾植入一行
|
||||
> `<!-- probe: docs/API.md -->` → 命中;删除后 → 无输出。
|
||||
> ⚠️ 注意这些 `.md` 多为 **CRLF** —— 用脚本批量删除/替换时**必须按 `\r?\n` 处理**,
|
||||
> 否则会漏改(我第一次就踩了:`\n` 正则配不上 `\r\n`,以为删干净了其实残留)。
|
||||
4. **改 `AGENTS.md` 的链接**:`AGENTS.md` 里所有 `docs/...` 链接都必须是**分类后**的路径
|
||||
(`docs/protocol/...`、`docs/tech/...`)。
|
||||
5. **别把权威内容只放进归档**:归档是留痕,规范要写进 `AGENTS.md` 或 `tech/`。
|
||||
@ -37,7 +37,7 @@
|
||||
|
||||
### 🔴 不一致:SOC/SOH 单位标注错误
|
||||
|
||||
**问题:** `docs/API.md` 第206-207行标注错误。
|
||||
**问题:** `docs/tech/API.md` 第206-207行标注错误。
|
||||
|
||||
```diff
|
||||
- soc — %(0-100)
|
||||
@ -61,8 +61,8 @@
|
||||
|------|------|-------|------|
|
||||
| `api-server/src/routes/h5.rs:309` | `today_energy_kwh` | SUM(energy) → Wh | 注释写"kWh"但返回 Wh |
|
||||
| `api-server/src/routes/h5.rs:776` | `energy_kwh` | `r.energy` → Wh | 字段名标 kWh,实际 Wh |
|
||||
| `docs/API.md:137` | `today_energy_kwh` | 0 | 文档无明确单位说明 |
|
||||
| `docs/API.md:421` | 多个能量字段 | — | **已有注释**:`¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。` |
|
||||
| `docs/tech/API.md:137` | `today_energy_kwh` | 0 | 文档无明确单位说明 |
|
||||
| `docs/tech/API.md:421` | 多个能量字段 | — | **已有注释**:`¹ 注意:`energy_kwh` / `today_energy_kwh` 为历史遗留命名,实际值仍为 **Wh**,前端仍需 `/ 1000`。` |
|
||||
|
||||
**现状:** API.md 第421行已有明确注释说明这是历史遗留命名。前端 H5 通过 `fmtEnergy` 和 `fmtEnergyKwh` 正确执行 `/1000` 转换。
|
||||
|
||||
@ -117,12 +117,12 @@ DB 层所有能量字段统一 Wh,SOC/EOC 统一 ‰,与协议完全一致
|
||||
|
||||
| 优先级 | 问题 | 文件 | 修改 |
|
||||
|--------|------|------|------|
|
||||
| 🔴 | SOC/EOC 单位标注 | `docs/API.md:206-207` | `%(0-100)` → `‰(0-10000)` |
|
||||
| 🔴 | SOC/EOC 单位标注 | `docs/tech/API.md:206-207` | `%(0-100)` → `‰(0-10000)` |
|
||||
| 🟡 | `fmtEnergy` 缺单位标签 | `h5-uniapp/compartment-detail/index.vue:143` | 加 `' kWh'` 后缀 |
|
||||
|
||||
### 已有保障(无需修复)
|
||||
|
||||
- `docs/API.md:421` 已对 `energy_kwh` / `today_energy_kwh` 的命名陷阱做注释
|
||||
- `docs/tech/API.md:421` 已对 `energy_kwh` / `today_energy_kwh` 的命名陷阱做注释
|
||||
- 前端所有 7 个单位转换函数逻辑正确
|
||||
- DB 层所有能量字段统一 Wh,SOC 统一 ‰,与协议一致
|
||||
|
||||
@ -120,14 +120,14 @@
|
||||
|
||||
1. **协议文档完整**:
|
||||
- ✅ `docs/tcp-protocol.md` - TCP 设备通讯协议(11条指令详细说明)
|
||||
- ✅ `docs/API.md` - API 接口文档(包含请求/响应示例)
|
||||
- ✅ `docs/1TN协议标准_YG20240118-1_V1.0.md` - 电源协议
|
||||
- ✅ `docs/tech/API.md` - API 接口文档(包含请求/响应示例)
|
||||
- ✅ `docs/protocol/1TN协议标准_YG20240118-1_V1.0.md` - 电源协议
|
||||
|
||||
2. **开发文档详细**:
|
||||
- ✅ `docs/开发文档.md` - 硬件架构、软件架构、通讯协议设计
|
||||
- ✅ `docs/附件一-硬件规格.md` - 柜控主板、仓控主板硬件规格
|
||||
- ✅ `docs/附件二-软件规格.md` - 后台管理系统、微信小程序需求
|
||||
- ✅ `docs/附件三-交付与验收标准.md` - 交付物清单、验收标准
|
||||
- ✅ `docs/archive/开发文档.md` - 硬件架构、软件架构、通讯协议设计
|
||||
- ✅ `docs/contract/附件一-硬件规格.md` - 柜控主板、仓控主板硬件规格
|
||||
- ✅ `docs/contract/附件二-软件规格.md` - 后台管理系统、微信小程序需求
|
||||
- ✅ `docs/contract/附件三-交付与验收标准.md` - 交付物清单、验收标准
|
||||
|
||||
3. **AGENTS.md 非常详细**:
|
||||
- ✅ 项目简介、技术栈、项目结构
|
||||
@ -141,7 +141,7 @@
|
||||
| 问题 | 严重程度 | 说明 |
|
||||
|------|----------|------|
|
||||
| **README.md 与 AGENTS.md 重复** | 🟢 低 | 两份文档内容有大量重复,维护成本增加 |
|
||||
| **合同模板未填写** | 🟢 低 | `docs/开发合同.md` 是空模板,未填写实际信息 |
|
||||
| **合同模板未填写** | 🟢 低 | `docs/contract/开发合同.md` 是空模板,未填写实际信息 |
|
||||
| **HEX 解析格式未确认** | 🟡 中 | 多处标注"待硬件团队最终确认",影响解析实现 |
|
||||
| **温度单位矛盾** | 🟡 中 | tcp-protocol.md 使用 `K×100`,AGENTS.md 描述为 `0.1°C` |
|
||||
|
||||
@ -198,7 +198,7 @@
|
||||
- 更新 README.md 和 AGENTS.md 保持一致性
|
||||
|
||||
7. **补充缺失的 API 实现**:
|
||||
- 验证所有 `docs/API.md` 中列出的接口是否都已实现
|
||||
- 验证所有 `docs/tech/API.md` 中列出的接口是否都已实现
|
||||
- 特别关注:能耗统计、告警管理、辅助设备控制
|
||||
|
||||
### 🟢 P2 - 中优先级
|
||||
165
docs/tech/小程序用户端.md
Normal file
165
docs/tech/小程序用户端.md
Normal file
@ -0,0 +1,165 @@
|
||||
# 微信小程序用户端(AGENTS.md 的详细参考篇)
|
||||
|
||||
> 本文件由 `AGENTS.md` 的「微信小程序用户端」整段搬迁而来(**内容逐字保留,未删改**)。
|
||||
> 页面清单、构建/上传流程、状态位与 RSSI 的分层展示口径都在这里。
|
||||
> 门禁命令(`vue-tsc` 类型检查 + `build:mp-weixin`)与「产物不上传服务器」已留在 `AGENTS.md`。
|
||||
|
||||
---
|
||||
|
||||
## 微信小程序用户端
|
||||
|
||||
### 小程序配置
|
||||
|
||||
| 参数 | 值 |
|
||||
|------|-----|
|
||||
| AppID | `wx226061c550c52e85` |
|
||||
| AppSecret | `f11dfee9dc73265b75bf54277ebb1047` |
|
||||
| 账号 | `13951516272@163.com` |
|
||||
| 消息加解密方式 | 明文模式 |
|
||||
| 数据格式 | XML |
|
||||
| 服务器地址(URL) | `https://app.anzhizhichong.com/api/wechat/callback` |
|
||||
| Token(令牌) | `7GW16XdnFyak0mE32rc8ZPij` |
|
||||
| EncodingAESKey | `kRijggSqeCFWi3JhfGyK0nzc6VmdygWWkoqyKPl0bYu` |
|
||||
|
||||
> ⚠️ 以上密钥仅记录在内网 Git 仓库 `生产环境.txt`,不入代码仓库。
|
||||
|
||||
### 构建命令
|
||||
|
||||
```bash
|
||||
cd software/h5-uniapp
|
||||
npm run build:mp-weixin
|
||||
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传
|
||||
```
|
||||
|
||||
### API 域名配置
|
||||
|
||||
小程序请求后端 API 必须配置合法域名(HTTPS):
|
||||
|
||||
- 后台「开发管理 → 开发设置 → 服务器域名」
|
||||
- request 合法域名:`https://app.anzhizhichong.com`
|
||||
- 开发阶段可在 `manifest.json` 设 `urlCheck: false` 跳过校验
|
||||
|
||||
### API base URL
|
||||
|
||||
小程序用全 URL(`api/index.ts` 条件编译):
|
||||
|
||||
```typescript
|
||||
// #ifdef MP-WEIXIN
|
||||
const BASE_URL = 'https://app.anzhizhichong.com/api/pms'
|
||||
// #endif
|
||||
// #ifndef MP-WEIXIN
|
||||
const BASE_URL = '/api/pms'
|
||||
// #endif
|
||||
```
|
||||
|
||||
### 信号强度显示(RSSI)
|
||||
|
||||
设备卡片右上角显示 RSSI 信号强度。
|
||||
|
||||
> ✅ **2026-09 已收敛为「两端同源同名、统一 5 档」**(此前两端不一致,见下方"行为变化")。
|
||||
> **唯一权威实现**:`web/src/utils/deviceStatus.ts` 与 `h5-uniapp/src/utils/deviceStatus.ts`
|
||||
> 的 `rssiSignal()` / `rssiBarColors()` / `RSSI_TIERS`(**两端逐字一致,改一边必须同步另一边**)。
|
||||
> 一致性用例:`node test/rssi_signal.test.mjs`(遍历 0–99 逐值断言两端 `level`/`bars`/`color` 相同)。
|
||||
> ⛔ **页面里不得再自己写一套阈值**(历史上两端各写一套,就是不一致的根因)。
|
||||
|
||||
**统一分档表(协议口径:`rssi` 是 0–99 的信号等级,不是 dBm)**
|
||||
|
||||
| 阈值 | `level` | 颜色 | 5 格制格数 | PC 图标亮的 `<path>` |
|
||||
|------|---------|------|-----------|---------------------|
|
||||
| ≥ 70 | 4 `strong` | `#16a34a` 绿 | 5 | 4 条全亮 |
|
||||
| ≥ 55 | 3 `good` | `#2563eb` 蓝 | 4 | 3 条 |
|
||||
| ≥ 40 | 2 `fair` | `#f59e0b` 黄 | 3 | 2 条 |
|
||||
| ≥ 30 | 1 `weak` | `#f97316` 橙 | 2 | 1 条 |
|
||||
| < 30 或 null | 0 `none` | `#9ca3af` 灰 | 1(底格) | 0 条(只剩底部圆点 `M2 20h.01`) |
|
||||
|
||||
**两端如何消费同一份纯函数:**
|
||||
- **PC**(`web/src/pages/devices/CabinetGrid.tsx::SignalIconComponent`):`rssiSignal(rssi)` 取 `color` 与 `level`,
|
||||
4 条 `<path>` 按 `level > i` 决定亮几条;卡片头部数值颜色用同一个 `color`。
|
||||
- **小程序**(`home/index.vue`、`device-detail/index.vue`):
|
||||
`v-for="(color, i) in rssiBarColors(cab.rssi)"` 渲染 `bars` 根 `<view class="signal-bar">`,
|
||||
颜色**内联** `:style="{ background: color }"`;**格数真的随档位变化**。
|
||||
- 颜色只在 TS 里定义一次(不再有 `.signal.good/.mid/.low/.off` 这类 CSS 颜色规则;
|
||||
页面 CSS 只负责尺寸/间距)。
|
||||
|
||||
**后端(两端共用):** 设备列表接口用 pipeline 批量 `HGET device:{imei} rssi`
|
||||
(`routes/cabinet_tree.rs::attach_realtime_status`,字段下标见 `DEVICE_LIST_FIELDS`);
|
||||
小程序侧 `routes/h5.rs::get_cabinets` 同样带 Redis。
|
||||
|
||||
**相关文件:**
|
||||
- 纯函数(权威): `software/web/src/utils/deviceStatus.ts`、`software/h5-uniapp/src/utils/deviceStatus.ts`
|
||||
- PC 消费点: `software/web/src/pages/devices/CabinetGrid.tsx`(`SignalIconComponent` + 卡片头部)
|
||||
- 小程序消费点: `software/h5-uniapp/src/pages/home/index.vue`、`pages/device-detail/index.vue`
|
||||
- 类型: `software/h5-uniapp/src/api/index.ts` — `CabinetItem.rssi`
|
||||
- 一致性用例: `test/rssi_signal.test.mjs`
|
||||
|
||||
> ⚠️ **行为变化(2026-09,小程序端显示会变 —— 这是有意为之)**
|
||||
> 收敛前:PC 5 档(≥70 绿/≥55 蓝/≥40 黄/≥30 橙/<30 灰,格数随档位增删);
|
||||
> 小程序只有 **4 档(无 ≥70)**、**30–39 用红色**、且**恒渲染 5 格只改颜色**。
|
||||
> 收敛后小程序:**新增 ≥70 档(绿/满格)**、**30–39 由红改橙**、**≥55 由绿改蓝**、
|
||||
> **离线灰由 `#94a3b8` 改为 `#9ca3af`**,且**格数随档位变化**(不再恒 5 格)。
|
||||
> 影响页面:小程序首页设备卡片、小程序设备详情页头部。
|
||||
|
||||
### 近期 UI 改动
|
||||
|
||||
1. **设备卡片头部**:**PC 管理端**(`web/src/pages/devices/CabinetGrid.tsx`)去掉"在线/离线"文字 badge,
|
||||
改为信号图标 + RSSI 数值。
|
||||
✅ **小程序保留"在线/离线"文字 badge —— 这是有意差异,不是缺陷,不要当 bug 改掉**:
|
||||
`h5-uniapp/src/pages/home/index.vue:43`(设备卡片,`cab.status===1 ? '在线' : '离线'`)与
|
||||
`pages/device-detail/index.vue:18`(设备详情;同文件 `:74` 还有**板级**的在线/离线 badge)。
|
||||
理由(产品决定):PC 用"信号图标 + RSSI 数值"表达;小程序屏幕小、文字 badge 更直观。
|
||||
> PC 卡片:**图标 + 数值**;小程序:**文字 badge**(有意保留)。
|
||||
另外 **PC 设备详情页**(`web/src/pages/devices/cabinet.tsx:503`)也仍用 `Tag` 渲染
|
||||
`CABINET_STATUS_MAP`(`0 离线 / 1 在线 / 2 禁用`)—— 只是**卡片**去掉了,别理解成"全平台都去掉了"。
|
||||
2. **通道统计**:图标+数字一行排列,图标用 `<svg>` 内联 Lucide(不加容器,flex 居中)
|
||||
3. **右上角扫码**:scan-line 图标(Lucide),灰色 #6b7280,居中垂直对齐
|
||||
|
||||
### 状态位展示约定(**状态位按归属层级展示**)
|
||||
|
||||
> 以下三条是**协议 §3.8 / §4.2 / §5.7 的既定结论**(产品已确认),后续不要再反复确认。
|
||||
|
||||
1. **柜级状态位 → 显示在"柜子"层面**
|
||||
- 数据:**⚠️ 这两个名字不是 DB 列**(实测 `cabinets` 表**没有** `status1`/`status2` 列,
|
||||
它的列是 `id/project_id/abstract_id/imei/iccid/auth_str/name/address/status/created_at/updated_at/
|
||||
latitude/longitude/work_mode/auth_str_issued/cell`)。它们是:
|
||||
**Redis 字段** `device:{imei}` 的 `cabinet_status1` / `cabinet_status2`(设备上报原样落,2 位 hex)
|
||||
→ 接口**原样透出**为响应字段 `status1` / `status2`。
|
||||
写:`report_worker.rs::write_device_state`;读:`cabinet_tree.rs::attach_realtime_status`(列表)、
|
||||
`cabinets.rs::fetch_cabinet_realtime`(详情)。
|
||||
- 位定义(§3.8):`status1` bit0 断电|bit1 烟雾|bit2 水位|bit3 倾倒|**bit4 柜门打开**|
|
||||
bit5 加热风扇|bit6 接触器断开|bit7 喷淋;`status2` bit0 声光报警|**bit1 柜门打开超时**
|
||||
(阈值硬件定义,**平台只呈现、不自行判定**);`status2` bit2–7 为保留位,**不展示**。
|
||||
- 页面:PC `web/src/pages/devices/cabinet.tsx`、小程序 `h5-uniapp/src/pages/device-detail/index.vue`。
|
||||
- **取不到返回 `null`(未知),绝不落 `"00"`** —— `"00"` 是"全部正常"的有效值。
|
||||
2. **仓体级状态位 → 显示在"仓体"层面**
|
||||
- 数据:通道 18 字符 hex 的 `batt_status`(byte0) / `pow_status`(byte1);保留位
|
||||
(`batt_status` bit2–3、`pow_status` bit3–6)**不展示**。
|
||||
- 页面:PC 柜子详情里的通道卡片、小程序 `pages/compartment-detail/index.vue`。
|
||||
- **仓体层面不显示任何"门"状态**:协议**没有**"按仓体(舱门)开门"的指令(§4.2 L403)。
|
||||
3. **柜门是柜级独立动作,不与业务联动,全平台只有一个开门按钮**
|
||||
- `open` 是**柜级**指令(**不带 `id`**),语义是**「柜门解锁,下发后由人拉开,门不会自动开」**;
|
||||
没有"关门"指令(关门也是人为动作)。**全平台只有一扇柜门**,不按仓体区分。
|
||||
- **柜门状态永远来自 `status_post`(约 60 秒一次),不是实时量** —— 界面上必须标注
|
||||
"来自最近一次上报(约 60 秒一次),非实时"(§4.2 L428)。
|
||||
- 开门是**独立流程**,与电池插入、充电开始/结束**没有因果关系也没有先后要求**(§5.7、§7.2):
|
||||
不得出现在"开始充电"流程里、不得改任何订单状态、不得要求存在订单。
|
||||
- 入口只有一个:PC 详情页按钮(`<Permission code="charge:open_door">` 包裹)+
|
||||
小程序设备详情页按钮;**仓体(通道)详情页不放开门按钮**。
|
||||
- 权限码 `charge:open_door`(后端 `check_permission` 硬校验;`device:door:unlock` 是历史误用的
|
||||
不存在权限码,**禁止再用**)。
|
||||
4. **解析函数两端必须同源同名**:`web/src/utils/deviceStatus.ts` 与
|
||||
`h5-uniapp/src/utils/deviceStatus.ts` 各有一组 `parseCabinetStatus1` / `parseCabinetStatus2` /
|
||||
`cabinetDoorState` / `cabinetStatusTags`(注释互相指认对方文件,改一边务必同步另一边)。
|
||||
端到端一致性用例见 `test/device_status_parse.test.mjs`(`node test/device_status_parse.test.mjs`,
|
||||
逐位遍历 0x00–0xFF 断言两端一致)。
|
||||
5. **`cabinets.status` 是「启用/禁用」配置态,不表示在线** —— `1` 启用 / `2` 禁用(`0` 是建表默认值
|
||||
产生的取值,代码不显式写它,语义等同启用);实时在线一律以 Redis `device:{imei}.online` 为准
|
||||
(唯一权威判据 `routes/cabinet_tree.rs::device_online_flag`,接口字段 `is_online`),
|
||||
`energy_stats` 的 `online_count`、H5 首页 `online_cabinets`、PC/小程序的「在线/离线」全部消费它
|
||||
(`CABINET_STATUS_MAP` 只保留启用/禁用两档,**不要再出现"离线"档**)。
|
||||
|
||||
### 开发约束(uni-app 特定)
|
||||
|
||||
1. **`<text>` 不支持 `display: flex`** — uni-app 的 `<text>` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `<view>`
|
||||
2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠
|
||||
3. **内联 SVG + v-if**:Vue 3 支持在 `<path>` 上用 `v-if` 条件渲染,适用于动态图标
|
||||
4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `<svg>` 上写死
|
||||
171
docs/tech/数据模型.md
Normal file
171
docs/tech/数据模型.md
Normal file
@ -0,0 +1,171 @@
|
||||
# 数据模型(AGENTS.md 的详细参考篇)
|
||||
|
||||
> 本文件由 `AGENTS.md` 的「数据模型」整段搬迁而来(**内容逐字保留,未删改**)。
|
||||
> **改任何表结构前必须读本文件**:建表顺序即依赖顺序(写错 MySQL 报 1824)、
|
||||
> 外键/唯一键清单、以及各表逐列语义都在这里。
|
||||
> 两条硬规则已留在 `AGENTS.md`(时间列必须 `DATETIME`、`CREATE TABLE IF NOT EXISTS`
|
||||
> 不改已存在列 ⇒ 必须同步 `LEGACY_COLUMN_TARGETS`)。
|
||||
|
||||
---
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 核心关系
|
||||
|
||||
```
|
||||
organization(组织/企业)
|
||||
├── project(项目/放置地点)
|
||||
│ └── cabinet(充电柜)
|
||||
│ ├── cabin_board(仓控板×N) board_id 1–15
|
||||
│ │ └── compartment(仓体×6) channel_id 1–8
|
||||
│ ├── charge_order(充电订单,进行中/已结算;active_flag 唯一键防并发)
|
||||
│ ├── charge_record(充电记录,结算后留痕)
|
||||
│ ├── energy_stat(能耗统计,按 柜×日 唯一)
|
||||
│ └── device_log(设备日志)
|
||||
├── user(用户) ──┬── user_roles ── role ── role_permissions ── permission
|
||||
│ ├── user_scopes(数据范围,项目级生效)
|
||||
│ └── operation_log(操作日志) / user_feedback(问题反馈) / download_tasks(下载中心)
|
||||
└── abstract_binding(平台抽象ID 10-xxxxxxxx ↔ cabinet 一对一)
|
||||
|
||||
battery(电池台账,按 battery_id 唯一) ── sim_change_log(换 SIM 记录,按 imei)
|
||||
```
|
||||
|
||||
**外键与唯一键要点(改动前必读)**
|
||||
- `compartments` 的父是 `cabin_boards`,`cabin_boards` 的父是 `cabinets`,`cabinets` 的父是 `projects`;
|
||||
建表语句**顺序即依赖顺序**(`migrate.rs::SCHEMA_SQL`,被引用表必须先建,否则 MySQL 报 1824)。
|
||||
- `charge_orders` / `charge_records` 同时外键到 `compartments` 与 `cabinets`。
|
||||
- `energy_stats` 有 `UNIQUE(cabinet_id, stat_date)`;`abstract_bindings` / `batteries` / `cabinets.imei`(唯一) 同理。
|
||||
|
||||
### 数据库表
|
||||
|
||||
**实测:测试库 `pms_dev` 与生产库 `pms` 各 21 张表**(依据:
|
||||
`SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA=...`,两库表名完全一致)。
|
||||
|
||||
| 表名 | 说明 |
|
||||
|------|------|
|
||||
| `organizations` | 组织/企业(`parent_id` 支持父组织,NULL=顶级) |
|
||||
| `projects` | 项目/放置地点(隶属组织) |
|
||||
| `cabinets` | 充电柜(`imei` 唯一、`abstract_id`、`auth_str`+`auth_str_issued`、`cell`、`work_mode`、`status`、经纬度、`address`) |
|
||||
| `cabin_boards` | 仓控板(`board_id` 1–15) |
|
||||
| `compartments` | 仓体/通道(`channel_id` 1–8;`status` **只表示占用** 0 空闲/1 充电中,充满与故障不落库) |
|
||||
| `batteries` | 电池台账(`battery_id` 唯一、SOC‰、厂家/型号/材料代码原值、额定电压容量、状态) |
|
||||
| `charge_orders` | 充电订单(进行中/已结算;`reason` normal/user_stop/forced/timeout;`active_flag` 唯一键防并发建单) |
|
||||
| `charge_records` | 充电记录(结算后留痕;`energy` 单位 **Wh**、`max_temperature` K×100) |
|
||||
| `device_logs` | 设备日志(`log_type` 1 状态上报 / 2 故障 / 3 告警 / 4 停电) |
|
||||
| `energy_stats` | 能耗统计(按 `cabinet_id` + `stat_date` 唯一,`total_energy` 单位 **Wh**) |
|
||||
| `operation_logs` | 操作日志(按 `user_id` 归属组织;**不叠加项目级数据范围**) |
|
||||
| `users` | 用户(`phone` 唯一、argon2 密码、`role` 0/1/2、`organization_id`) |
|
||||
| `roles` | 角色(内置 `super_admin` / `org_admin` / `normal_user`) |
|
||||
| `permissions` | 权限码字典(31 条,见 `migrate.rs::seed_permissions`) |
|
||||
| `role_permissions` | 角色↔权限 关联(复合主键) |
|
||||
| `user_roles` | 用户↔角色 关联(复合主键;**为空时按 `users.role` 回退内置角色权限**,见 `auth.rs::load_user_permissions`) |
|
||||
| `user_scopes` | 数据范围(`scope_type` 1 组织 / 2 项目 / 3 设备;**只有 1、2 参与鉴权**) |
|
||||
| `download_tasks` | 下载中心/异步导出任务(`task_type`、`status` 0排队~3失败、`file_path`) |
|
||||
| `abstract_bindings` | 平台抽象ID ↔ 柜子 绑定(`abstract_id` / `cabinet_id` 均唯一) |
|
||||
| `sim_change_logs` | SIM 卡(ICCID)变更记录(`login` 时检测到变更即写) |
|
||||
| `user_feedback` | 用户问题反馈(内容/联系方式/处理状态) |
|
||||
|
||||
> 表清单的**权威来源**是 `software/api-server/src/db/migrate.rs::SCHEMA_SQL`(代码建表 DDL)
|
||||
> 与库实测两处,两者必须一致;不一致用 `python tools/db_schema_diff.py --vs-ddl` 查。
|
||||
|
||||
### Redis 实时状态存储
|
||||
|
||||
> **权威依据:`software/api-server/src/workers/report_worker.rs` 的模块头注释 + `write_device_state` /
|
||||
> `write_channel_state` / `handle_pow_fail` / `save_channel_battery`(写入侧),
|
||||
> `workers/energy.rs::order_energy_key`(累加器),`device-server/src/tcp/server.rs::update_device_route`(路由)。**
|
||||
> 下表是**逐字段核对代码后的实际写入契约**,不是设计稿。
|
||||
|
||||
**层级结构(实测:测试库 db6 的真实 key/hash 与下表完全一致):**
|
||||
|
||||
```
|
||||
device:{imei} # 设备级 Hash(TTL 300 秒,写它的每一条路径都会 EXPIRE 300)
|
||||
├─ online = "1" # 在线标记(login 成功 / status_post 均会置)
|
||||
├─ rssi = "85" # 信号强度(协议口径 0–99 等级,非 dBm)
|
||||
├─ temp = "29800" # 柜温 K×100(status_post 的 cabinet.temp)
|
||||
├─ meter = "123456" # 电表读数(status_post 的 cabinet.meter)
|
||||
├─ cabinet_status1 = "01" # 柜级状态位 byte0(2 位 hex,**取不到不写**)
|
||||
├─ cabinet_status2 = "00" # 柜级状态位 byte1(2 位 hex,**取不到不写**)
|
||||
├─ pow_fail_dc = "0/1" # 直流停电(接触器分闸)
|
||||
├─ pow_fail_ac = "0/1" # 交流停电(柜控板自身断电)
|
||||
└─ node_id = "node-1" # 该设备 TCP 连接所在节点(device-server 写,用于多节点路由)
|
||||
|
||||
device:{imei}:board:{板号}:ch:{通道号} # 通道级 Hash(**唯一的状态载体,无 TTL**)
|
||||
├─ batt_status = "82" # byte0 电池状态(2 位小写 hex,位定义见协议 §3.8)
|
||||
├─ pow_status = "82" # byte1 电源状态(2 位小写 hex,与电池状态独立)
|
||||
├─ soc = "850" # SOC ‰(硬件 1% → ×10)
|
||||
├─ voltage = "54000" # 电压 mV(硬件 0.01V → ×10)
|
||||
├─ current = "8000" # 电流 mA(硬件 0.01A → ×10)
|
||||
└─ temp = "29800" # 电芯最高温 K×100(硬件 1K → ×100)
|
||||
|
||||
device:{imei}:board:{板号}:ch:{通道号}:bat # 通道电池台账(内部键,TTL 48h,bat_in 登记)
|
||||
├─ battery_id / soc / info / brand_code / model_code / material_code / cell_count
|
||||
└─ (rated_voltage / rated_capacity)
|
||||
|
||||
device:{imei}:board:{板号}:ch:{通道号}:order # 通道订单电量累加器(内部键,Lua 原子累加)
|
||||
├─ mwh / maxtemp / ts / nobat
|
||||
└─ (由 workers/energy.rs 维护,供结单取电量)
|
||||
```
|
||||
|
||||
> ⚠️ **关键事实(旧文档写错了,逐条纠正):**
|
||||
> 1. **板级 hash `device:{imei}:board:{板号}` 根本不存在** —— 现协议板级没有状态字节,
|
||||
> 写入侧**只写通道级 key**。`/api/cabinets/realtime-status` 响应里那个 `boards[].status_hex`
|
||||
> 是**接口现拼**的(各通道 `batt_status + pow_status` 按通道号升序拼接),**不是一个 Redis 键**。
|
||||
> 生产 Redis 里若还能搜到 `device:*:board:N` 这种 hash(内容只有 `status_hex`),那是**旧版本残留**。
|
||||
> 2. **通道号是 1 基(1–8),不是 0 基**;老数据里出现过 `...:ch:0`,也是旧版本残留。
|
||||
> 板号同样从 1 开始,量程 1–15。
|
||||
> 3. **通道级字段是 `batt_status` / `pow_status` / `soc` / `voltage` / `current` / `temp`**,
|
||||
> **没有** `on` / `full` / `fault` / `hex` 这四个字段(那是旧格式;`on/full/fault/hex` 是接口
|
||||
> 由 `batt_status`/`pow_status` **派生出来的响应字段**,不是存储字段)。
|
||||
> 4. **板号和通道号十进制不补零**(`board:1:ch:3`,不是 `board:01:ch:03`)。
|
||||
> 5. 设备级 key 的**字段可能是残缺的**(如只有 `online`+`rssi`):`cabinet_status1/2` 只在
|
||||
> `status_post` 带合法 `cabinet.status1/2` 时才写;**读不到就是"无数据",绝不落 `"00"`**。
|
||||
> 6. `device:{imei}` 的 **TTL 就是在线判据**(300 秒内无任意上行即判离线),
|
||||
> 所以**任何写 `device:{imei}` 的代码都必须跟一条 `EXPIRE 300`**。
|
||||
> 7. hex 编码格式**已定稿**(协议 §3.8 的位定义表),**不再有"待硬件团队确认"的悬置项**。
|
||||
|
||||
**写入时机:**
|
||||
|
||||
| 事件 | 写入内容 |
|
||||
|------|----------|
|
||||
| `login`(验签通过) | `device:{imei}`:`online=1` + `EXPIRE 300`;另写 `node_id`(device-server 侧) |
|
||||
| `status_post` | `device:{imei}`:`online/rssi/temp/meter`(+`cabinet_status1/2`) + `EXPIRE 300`;<br>逐板逐通道 `device:{imei}:board:{b}:ch:{c}` 的 6 个字段;<br>`cabinet.status1` bit0=0 时**顺带把 `pow_fail_dc`/`pow_fail_ac` 清 0**(供电恢复);<br>并累加 `...:order` 的电量 |
|
||||
| `pow_fail_dc` | `device:{imei}.pow_fail_dc = 1` |
|
||||
| `pow_fail_ac` | `device:{imei}.pow_fail_ac = 1` |
|
||||
| `bat_in` | `device:{imei}:board:{b}:ch:{c}:bat`(电池台账,TTL 48h) |
|
||||
| 任意上行报文 | device-server 刷新 `device:{imei}` 的 TTL/`node_id`(路由用) |
|
||||
|
||||
> ⛔ **没有 `pow_fail` 这条 act**:停电是**两条独立指令** `pow_fail_dc`(直流,接触器分闸→仓控板下电)
|
||||
> 与 `pow_fail_ac`(交流,柜控板自身断电),各写**同名字段**(协议 §5.4 / §10 第 11 条)。
|
||||
> `report_worker.rs:237` 的 `"pow_fail_dc" | "pow_fail_ac" => handle_pow_fail(...)` 是唯一入口。
|
||||
|
||||
**通道状态位(`batt_status` / `pow_status`)—— 按协议 §3.8 位定义,不按"五态枚举":**
|
||||
|
||||
`batt_status`(byte0):bit0 充满|bit1 正在充电|bit2–3 保留(不展示)|bit4 过欠压|bit5 过流|
|
||||
bit6 热失控|**bit7 有电池**(决定"空仓/在位")。
|
||||
`pow_status`(byte1):bit0 电源故障|bit1–2 电源状态位|bit3–6 保留(不展示)|bit7 电源有效。
|
||||
|
||||
**全仓唯一权威推导规则**(`routes/cabinet_tree.rs::derive_channel_flags`,前端
|
||||
`web/src/utils/deviceStatus.ts` 与 `h5-uniapp/src/utils/deviceStatus.ts` 同源):
|
||||
|
||||
```
|
||||
展示状态 = fault > full > charging > idle
|
||||
fault = batt_status bit4|bit5|bit6 或 pow_status bit0
|
||||
full = batt_status bit0
|
||||
charging = batt_status bit1
|
||||
无数据(batt_status / pow_status 都取不到)= has_data:false
|
||||
→ 列表计数按 0 计、详情保留 DB 占用值,**绝不猜测**
|
||||
```
|
||||
|
||||
> 旧文档那张"空闲/插入/充电中/充满/故障"五态表**已废弃**:它既不是协议口径,
|
||||
> 也无法表达"电池在位但未充电"(`batt_status` bit7=1、bit0/bit1=0)这类真实状态。
|
||||
|
||||
**API 接口(接口总表以 [docs/tech/API.md](docs/tech/API.md) 为准,这里只列与 Redis 存储强相关的两个):**
|
||||
|
||||
| 接口 | 说明 | Redis 查询方式 |
|
||||
|------|------|----------------|
|
||||
| `GET /api/cabinets/filter` | 设备列表(设备级状态 + 充满/故障计数) | ① Pipeline `HGET` 每柜 6 个设备级字段(`online/rssi/pow_fail_dc/pow_fail_ac/cabinet_status1/cabinet_status2`,1 次往返);② 通道计数:**按 DB 的板号/通道号拼 key**(`fetch_channel_keys`,SQL 取拓扑)+ pipeline `HMGET(batt_status, pow_status)` —— **零 KEYS 扫描** |
|
||||
| `GET /api/cabinets/realtime-status?imei=xxx` | 单设备详情(板级+通道级) | `HMGET` 取设备级 4 字段;板/通道:**按 DB 拓扑(`cabinets→cabin_boards→compartments`)拼候选 key + 一次 pipeline `HGETALL`**,空 map = 键不存在(等价于只返回存在的键)。<br>✅ **2026-09 已修**:旧实现用 `KEYS device:{imei}:board:*:ch:*`,在**与既有充电站平台共用**的 Redis 实例上会阻塞单线程(实测每次请求 `cmdstat_keys` +1),已彻底移除 |
|
||||
|
||||
> ⛔ **本项目禁止在任何请求路径上用 `KEYS`** —— Redis 实例是共用的,`KEYS` 会阻塞单线程遍历整个
|
||||
> keyspace。要枚举就用「按 DB 拓扑拼 key」或 `SCAN` 游标。查是否有人用了:
|
||||
> `grep -rn '"KEYS"' software/*/src/`。
|
||||
374
docs/tech/部署手册.md
Normal file
374
docs/tech/部署手册.md
Normal file
@ -0,0 +1,374 @@
|
||||
# 部署手册(AGENTS.md 的详细参考篇)
|
||||
|
||||
> 本文件由 `AGENTS.md` 的「部署与维护」整段搬迁而来(**内容逐字保留,未删改**)。
|
||||
> 搬迁原因:`AGENTS.md` 超过工作区指令预算(65,536 B)会被按头部截断,尾部不加载。
|
||||
>
|
||||
> **判据与最致命的几条已留在 `AGENTS.md`**:改线上前先读那里的「部署前必看」+
|
||||
> 「部署注意事项」摘要;本文件给出**完整步骤、nginx 全文、以及全部 11 条注意事项**。
|
||||
|
||||
---
|
||||
|
||||
## 部署与维护
|
||||
|
||||
### SSH连接
|
||||
|
||||
```bash
|
||||
# 外网IP(推荐)
|
||||
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
|
||||
|
||||
# ZeroTier内网IP
|
||||
echo y | plink -ssh root@10.8.0.252 -pw "Hbhyg731024@"
|
||||
```
|
||||
|
||||
### 数据库连接
|
||||
|
||||
⛔ **不要再用 `10.8.0.252` 连库** —— 服务器 nginx `stream{}` 转发已于 2026-09-20 移除,
|
||||
`10.8.0.252:3306/6379`(以及 `127.0.0.1` 回环)**都连不到了**(实测 ✅)。
|
||||
**所有本地数据库操作一律先起 SSH 隧道**(`pwsh tools/dev-tunnel.ps1`),再连 `127.0.0.1:13306/16379`。
|
||||
|
||||
**MySQL(通过 SSH 隧道):**
|
||||
```bash
|
||||
# 先起隧道(另一个终端常驻)
|
||||
pwsh tools/dev-tunnel.ps1
|
||||
|
||||
# 命令行
|
||||
mysql -h 127.0.0.1 -P 13306 -u root -p
|
||||
# 密码:Hbhyg731024@
|
||||
# 测试环境库:pms_dev
|
||||
# 正式环境库:pms
|
||||
```
|
||||
|
||||
```python
|
||||
# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
|
||||
import os, pymysql
|
||||
host = os.environ.get('PMS_MYSQL_HOST', '127.0.0.1')
|
||||
port = int(os.environ.get('PMS_MYSQL_PORT', '13306'))
|
||||
conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms') # 正式库
|
||||
conn = pymysql.connect(host=host, port=port, user='root', password='Hbhyg731024@', database='pms_dev') # 测试库
|
||||
```
|
||||
|
||||
**Redis(通过 SSH 隧道):**
|
||||
```bash
|
||||
# 命令行
|
||||
redis-cli -h 127.0.0.1 -p 16379 -a "Hbhyg731024@"
|
||||
# 测试环境:select 6
|
||||
# 正式环境:select 5
|
||||
```
|
||||
|
||||
```python
|
||||
# Python(Agent 常用方式)—— 默认走隧道,可用环境变量指向别处
|
||||
import os, redis
|
||||
host = os.environ.get('PMS_REDIS_HOST', '127.0.0.1')
|
||||
port = int(os.environ.get('PMS_REDIS_PORT', '16379'))
|
||||
r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=5) # 正式环境
|
||||
r = redis.Redis(host=host, port=port, password='Hbhyg731024@', db=6) # 测试环境
|
||||
```
|
||||
|
||||
**数据库工具 `tools/db.py`(需先起隧道):**
|
||||
```bash
|
||||
python tools/db.py tables # 列出所有表
|
||||
python tools/db.py count # 统计各表数据量
|
||||
python tools/db.py query "SELECT * FROM users" # 查询
|
||||
python tools/db.py exec "UPDATE users SET ..." # 写入
|
||||
```
|
||||
> **`tools/db.py` 默认连测试库 `pms_dev`**(可用 `PMS_MYSQL_DB=pms` 切生产,切换时会打印醒目警告);
|
||||
> 连接失败会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 exit 2。
|
||||
|
||||
### Nginx配置
|
||||
|
||||
> ⚠️ **下面是"骨架节选",不是线上全文** —— 线上配置还包含(2026-09 实测):
|
||||
> 安全响应头(`Strict-Transport-Security` / `X-Content-Type-Options` / `X-Frame-Options` /
|
||||
> `Referrer-Policy` / `Content-Security-Policy-Report-Only`)、
|
||||
> **限流 location**(`= /api/pms/auth/login`、`= /api/pms/auth/challenge`、`/api/pms/cabinets/abstract/`,配合
|
||||
> `/etc/nginx/nginx.conf` 里的 `limit_req_zone auth_login_zone / auth_challenge_zone / uploads_zone / scan_abstract_zone`)、
|
||||
> `location /uploads/`(免认证同源直出 + 限流 + `expires 30d`)、生产侧 `location = /pms { return 301 /pms/; }`、
|
||||
> 测试侧 `location /h5/`。
|
||||
> **改动前先拉线上真实文件为准**:
|
||||
> `plink -ssh root@118.31.119.206 "cat /etc/nginx/conf.d/app.anzhizhichong.com.conf"`。
|
||||
> 仓库内 `deploy/nginx/` 是**三份配置的镜像**(`nginx.conf` 主配置 + `app`/`test` 两份 conf.d,
|
||||
> 均与线上**逐字节一致**)—— 改线上前先比对,见「部署注意事项」第 10 条。
|
||||
|
||||
**正式环境** (`/etc/nginx/conf.d/app.anzhizhichong.com.conf`):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.anzhizhichong.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name app.anzhizhichong.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/app.anzhizhichong.com.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/app.anzhizhichong.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# 产品介绍页
|
||||
root /data/landing;
|
||||
index index.html;
|
||||
|
||||
location /doc/ {
|
||||
proxy_pass http://127.0.0.1:3080/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
client_max_body_size 500m;
|
||||
}
|
||||
|
||||
# PMS API 入口(微服务统一前缀 /api/{服务})
|
||||
location /api/pms/ {
|
||||
proxy_pass http://127.0.0.1:9000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# PMS管理前端
|
||||
location /pms/ {
|
||||
alias /data/pms/web/;
|
||||
try_files $uri $uri/ /pms/index.html;
|
||||
}
|
||||
|
||||
# 微信回调(免认证)
|
||||
location /api/wechat/ {
|
||||
proxy_pass http://127.0.0.1:9000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**测试环境** (`/etc/nginx/conf.d/test.anzhizhichong.com.conf`):
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name test.anzhizhichong.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name test.anzhizhichong.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/test.anzhizhichong.com.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/test.anzhizhichong.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
root /data/pms_dev;
|
||||
index index.html;
|
||||
|
||||
# PMS API 入口(微服务统一前缀 /api/{服务})
|
||||
location /api/pms/ {
|
||||
proxy_pass http://127.0.0.1:3000/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /pms/ {
|
||||
try_files $uri $uri/ /pms/index.html;
|
||||
}
|
||||
|
||||
# 微信回调(免认证)
|
||||
location /api/wechat/ {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 测试环境SSL证书与正式环境共用(test是app证书的SAN域名),部署时复制pem+key即可。
|
||||
|
||||
### 部署步骤
|
||||
|
||||
> ✅ **推荐用一键脚本(在本地开发机执行)**:
|
||||
> ```bash
|
||||
> pwsh deploy/deploy-prod.ps1 # 完整部署:构建 → 校验产物 → 服务器备份 → 传 .new → 原子替换 → 重启 → 验收
|
||||
> pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,只重发现有产物
|
||||
> pwsh deploy/deploy-prod.ps1 -DryRun # 演练,不做任何改动
|
||||
> pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端
|
||||
> ```
|
||||
> 脚本会自动做:产物路径校验、**上传后 `chmod +x`**、**传 `.new` 再原子 `mv`**(避开 ETXTBSY)、
|
||||
> `nginx -t` 后才 reload、验收(服务/端口/域名/ERROR 数)、以及**用不存在的 IMEI 探测上报消费链路**
|
||||
> (不碰真实设备数据),最后打印回滚方法。
|
||||
>
|
||||
> ⛔ **`deploy/deploy-prod.sh` 与 `deploy/deploy-test.sh` 已废弃**(执行即报错退出):
|
||||
> 它们在 `target/release/` 找产物、会安装重复的 `pms-api-prod` 单元、并会用本地 `.env`
|
||||
> 覆盖线上 `.env`(历史上该文件带行内注释,会让生产起不来)。下面手写步骤仅供参考。
|
||||
|
||||
**1. 本地编译**
|
||||
```bash
|
||||
# 后端(Rust)— Windows 交叉编译 Linux 目标
|
||||
cd software/api-server
|
||||
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
cd software/device-server
|
||||
cargo zigbuild --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
# 前端(React 管理端)
|
||||
cd software/web
|
||||
npm run build
|
||||
|
||||
# 微信小程序用户端(uni-app)
|
||||
cd software/h5-uniapp
|
||||
npm run build:mp-weixin
|
||||
# 产物在 dist/build/mp-weixin/,用微信开发者工具导入上传
|
||||
```
|
||||
|
||||
**2. 传输到服务器**
|
||||
|
||||
> 产物名以 `Cargo.toml` 的 `[package] name` 为准:**api-server → `pms-api`**
|
||||
> (`software/api-server/Cargo.toml:2`)、**device-server → `pms-device-server`**
|
||||
> (`software/device-server/Cargo.toml:2`)。⛔ 旧文档写的 `device-server` 是**产物名写错**,
|
||||
> 在 `target/.../release/` 下根本没有这个文件。
|
||||
|
||||
```bash
|
||||
# 传输后端二进制(测试环境)
|
||||
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms_dev/api-server/
|
||||
# ⚠️ 测试环境的落地文件名目前是 device-server(见 deploy/pms-device-test.service:11
|
||||
# 的 ExecStart=/data/pms_dev/device-server/device-server),与 Cargo.toml 的
|
||||
# pms-device-server 不一致 —— 这是既存事实,照它传,别改名(改名要同步改单元)
|
||||
scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms_dev/device-server/device-server
|
||||
|
||||
# 传输后端二进制(正式环境)—— 生产落地名就是 Cargo.toml 产物名
|
||||
scp software/api-server/target/x86_64-unknown-linux-gnu/release/pms-api root@118.31.119.206:/data/pms/api-server/
|
||||
scp software/device-server/target/x86_64-unknown-linux-gnu/release/pms-device-server root@118.31.119.206:/data/pms/device-server/
|
||||
|
||||
# 传输前端静态文件
|
||||
scp -r software/web/dist/* root@118.31.119.206:/data/pms_dev/web/ # 测试环境
|
||||
scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境
|
||||
```
|
||||
|
||||
> 服务器实测(2026-09,`ls -la` + `ss -lntp`):
|
||||
> `/data/pms/api-server/pms-api`(:9000)、`/data/pms/device-server/pms-device-server`(:9001)、
|
||||
> `/data/pms_dev/api-server/pms-api`(:3000)、`/data/pms_dev/device-server/device-server`(:3001)。
|
||||
|
||||
#### 部署注意事项(最容易踩的坑,逐条都栽过)
|
||||
|
||||
1. **上传后必须 `chmod +x`** —— 否则 systemd 报 `203/EXEC`,服务起不来。
|
||||
2. **运行中的二进制不可直接覆盖**(Linux `ETXTBSY`)—— 正确姿势:
|
||||
传到 `xxx.new` → `chmod +x` → `systemctl stop` → `mv -f xxx.new xxx` → `systemctl start`。
|
||||
(`deploy-prod.ps1` 已内置这套流程。)
|
||||
3. **`.env` 不支持行内注释** —— `PORT=9000 # 注释` 会让配置解析失败、服务起不来;
|
||||
注释必须**独占一行**。(历史上用带行内注释的本地 `.env` 覆盖线上,导致生产起不来。)
|
||||
4. **`exports` 目录必须预置、且属主为 `pms`** —— api-server 以 `User=pms` **降权运行**
|
||||
(`deploy/pms-api-*.service` 的 `User=pms`/`Group=pms`),它**无法在 `/data/pms`(root 属主)
|
||||
下自建目录**;`exports`(异步导出落盘)缺失或属主不对 → 导出任务直接失败。
|
||||
实测正确属主:`drwxr-xr-x pms pms /data/pms/api-server/exports`(`uploads` 同理)。
|
||||
5. **`worker_shutdown_timeout 30s`**(`/etc/nginx/nginx.conf` 第 2 行)—— 调短会让正在传输的
|
||||
响应被中断;**改 nginx 配置前先 `nginx -t`**,通过后再 `systemctl reload nginx`。
|
||||
6. **stream 转发已移除(2026-09-20)—— 不要再给开发机开库端口** —— 主配置
|
||||
`/etc/nginx/nginx.conf` 里原来那个 `stream{}`(MySQL 3306 / Redis 6379 → 阿里云 RDS/Redis)
|
||||
**已整块删除**;服务器不再监听 3306/6379(`10.8.0.252:3306/6379` 实测已不可连)。
|
||||
⛔ **不要为了给开发机开库通路而在服务器上重新加 `stream{}` / socat / 任何端口转发** ——
|
||||
开发机要连库一律走 **SSH 隧道**(`pwsh tools/dev-tunnel.ps1`,见「本地开发环境」);
|
||||
服务侧(`/data/pms`、`/data/pms_dev`)本来就直连阿里云,也不需要转发层。
|
||||
**历史原因(务必记住)**:`stream{}` 全配置**只能有一个**、且只能在主配置里 ——
|
||||
历史上曾在 `conf.d/*.conf` 里又写了一个,nginx 以
|
||||
`"stream" directive is duplicate` **直接启动失败、整站不可用**(含生产域)。
|
||||
所以:conf.d 下只放 `http` 上下文的 `server {}`;**主配置里现在应当完全没有 `stream{}`**
|
||||
(若哪天又看到它,先查清是谁加的)。
|
||||
7. **单元必须带 `EnvironmentFile=`** —— 现行二进制强制要求环境变量,缺它起不来:
|
||||
`EnvironmentFile=/data/pms/api-server/.env` / `/data/pms/device-server/.env`(测试环境同理)。
|
||||
8. **端口/库号串了就是事故** —— 生产 `:9000/:9001` + db5/`pms`;测试 `:3000/:3001` + db6/`pms_dev`。
|
||||
部署后务必核对 `ss -lntp` 与域名返回码(见「部署架构」的判断方法)。
|
||||
9. **Redis 实时键结构变更后必须清理旧键** —— 见「踩过的坑」第 6 条(无 TTL 的旧键会被当成实时值)。
|
||||
10. **nginx 主配置也有仓库镜像,改线上前必须先比对** —— `deploy/nginx/nginx.conf`
|
||||
(**与线上 `/etc/nginx/nginx.conf` 逐字节一致**,纯 LF、100 行、3733 字节、
|
||||
md5 `82f660d8ebd3299c89edf55483277266` —— 2026-09-20 移除 `stream{}` + 订正 stream 注释后重新对齐)。
|
||||
`deploy/nginx/` 下三份分别是:
|
||||
- `nginx.conf` ←→ 线上 `/etc/nginx/nginx.conf`(**主配置**:`limit_req_zone` / `worker_shutdown_timeout`;
|
||||
⚠️ 已无 `stream{}` —— 见第 6 条,别再加回去)
|
||||
- `app.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/app.anzhizhichong.com.conf`
|
||||
- `test.anzhizhichong.com.conf` ←→ 线上 `/etc/nginx/conf.d/test.anzhizhichong.com.conf`
|
||||
|
||||
**为什么必须有主配置镜像**:`limit_req_zone` 定义在**主配置**的 `http{}` 里,而 `conf.d/*.conf` 的
|
||||
`location` 引用了这些 zone。**只照仓库的 conf.d 重建 nginx,zone 就没了 ⇒ `nginx -t` 直接失败、整站起不来。**
|
||||
(历史已踩过 `stream{}` 重复、行内注释等同类问题。)
|
||||
|
||||
```bash
|
||||
# 改线上前:先确认线上与仓库镜像一致(不一致就先搞清是谁改的)
|
||||
plink -batch -ssh root@118.31.119.206 -pw "<pwd>" "md5sum /etc/nginx/nginx.conf /etc/nginx/conf.d/*.conf"
|
||||
md5sum deploy/nginx/nginx.conf deploy/nginx/*.conf # 纯 LF,可直接比对
|
||||
# 逐行比对(推荐,能看出差在哪)
|
||||
diff <(plink -batch -ssh root@118.31.119.206 -pw "<pwd>" "cat /etc/nginx/nginx.conf") deploy/nginx/nginx.conf
|
||||
```
|
||||
改完线上后**把仓库镜像同步更新**(双向一致),否则下次比对全是噪声。
|
||||
11. **新增域名/新增路径要限流,按这个顺序落(三步缺一不可)**:
|
||||
1. **主配置 `http{}` 加 zone**(`deploy/nginx/nginx.conf` + 线上 `/etc/nginx/nginx.conf`):
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=<name>_zone:10m rate=<N>r/m;
|
||||
```
|
||||
⛔ zone 名要唯一;`limit_req_status 429;` 只需在主配置写一次(已在)。
|
||||
> 现有 zone(2026-09 实测 4 个):`auth_login_zone` 10r/m、`auth_challenge_zone` 20r/m、
|
||||
> `uploads_zone` 120r/m、**`scan_abstract_zone` 30r/m** —— 只挂在
|
||||
> `location /api/pms/cabinets/abstract/`(**免认证**的扫码查设备入口,无 token 可枚举 `10-%08d`,
|
||||
> 是真实攻击面)。
|
||||
> ⚠️ **`/api/cabinets/scan/{abstract_id}` 不在这个 zone 里**(前缀 `abstract/` 匹配不到 `scan/`)——
|
||||
> 它**需登录**、无 token 恒 `401`(与 id 是否存在无关),**不构成枚举信号,故无需限流**。
|
||||
> 实测:`scan` 同 IP 60 连发 = `401`×60 / `429`×0;`abstract` 同法 = `404`×23 / `429`×37。
|
||||
2. **对应的 `conf.d/*.conf` 里给那个 location 挂 `limit_req`**:
|
||||
```nginx
|
||||
location = /api/pms/<path> {
|
||||
limit_req zone=<name>_zone burst=N nodelay;
|
||||
proxy_pass http://127.0.0.1:<port>/api/<path>;
|
||||
...
|
||||
}
|
||||
```
|
||||
⛔ **`limit_req` 引用的 zone 必须在主配置里存在**,否则 `nginx -t` 报
|
||||
`unknown limit_req_zone "<name>_zone"` 而拒绝启动。
|
||||
3. **先 `nginx -t`,通过后才 reload**:`nginx -t && systemctl reload nginx`。
|
||||
⛔ 不要跳过 `-t` 直接 `restart` —— 配置错会让**整站**(含生产域)起不来。
|
||||
4. 把线上改动同步回 `deploy/nginx/` 的对应文件(见第 10 条),并在报告里注明。
|
||||
|
||||
> 💡 **经验(写限流文档时必守)**:写"某 zone 覆盖某某路径"时,**必须按 `location` 的前缀语义核对,
|
||||
> 并附实测 `429` 的路径清单** —— 不要按 zone 的名字推断它管谁
|
||||
> (`scan_abstract_zone` 这名字本身就极易让人以为它管 `scan`,而它的 `location` 只有
|
||||
> `/api/pms/cabinets/abstract/`;`scan/` 落在无 `limit_req` 的通用 `location /api/pms/`)。
|
||||
|
||||
**2.1 微信小程序上传**
|
||||
|
||||
小程序构建产物 `dist/build/mp-weixin/` 不上传到服务器,而是通过微信开发者工具上传:
|
||||
|
||||
1. 打开微信开发者工具
|
||||
2. 导入项目目录 `software/h5-uniapp/dist/build/mp-weixin`
|
||||
3. 填入 AppID(已配置在 `manifest.json`)
|
||||
4. 预览/真机调试 → 上传体验版/正式版
|
||||
|
||||
**3. 服务器重启服务**
|
||||
```bash
|
||||
# SSH到服务器
|
||||
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"
|
||||
|
||||
# 正式环境
|
||||
systemctl restart pms-api
|
||||
systemctl restart pms-device
|
||||
|
||||
# 重启Nginx
|
||||
systemctl restart nginx
|
||||
```
|
||||
|
||||
### 服务管理
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
systemctl status pms-api # 正式环境API
|
||||
systemctl status pms-device # 正式环境TCP
|
||||
systemctl status nginx
|
||||
|
||||
# 查看日志
|
||||
journalctl -u pms-api -f
|
||||
journalctl -u pms-device -f
|
||||
tail -f /var/log/nginx/error.log
|
||||
```
|
||||
@ -383,7 +383,7 @@ pub async fn run(pool: &MySqlPool) -> Result<(), sqlx::Error> {
|
||||
// 且 `routes/charge_orders.rs::status_text` 把 `2` 映射为"异常" ——
|
||||
// 即"异常"曾被设计成一个**状态**,而现实现把它降级成 `reason` 里的**结束原因**。
|
||||
// 这是**产品语义缺口,不是笔误**,故**不删除 `2`**,只在注释里注明"保留未使用",
|
||||
// 并作为待产品决策项上报(见 docs/API.md 的 `GET /api/charge-orders` 一节)。
|
||||
// 并作为待产品决策项上报(见 docs/tech/API.md 的 `GET /api/charge-orders` 一节)。
|
||||
ensure_column_comment(
|
||||
pool,
|
||||
"charge_orders",
|
||||
|
||||
@ -711,7 +711,7 @@ struct CabinetRealtime {
|
||||
|
||||
/// 单通道实时状态(与 `report_worker::write_channel_state` 写入的 Hash 字段一一对应)
|
||||
///
|
||||
/// 单位口径与 `docs/API.md` 一致:`soc` ‰ / `voltage` mV / `current` mA / `temp` K×100,
|
||||
/// 单位口径与 `docs/tech/API.md` 一致:`soc` ‰ / `voltage` mV / `current` mA / `temp` K×100,
|
||||
/// `batt_status` / `pow_status` 为 2 位 hex 字符串。
|
||||
#[derive(Default, Clone, Debug)]
|
||||
struct ChannelRealtime {
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
//! 执行业务逻辑:签名验证、状态持久化、充电事件处理、指令回执配对等。
|
||||
//! 需要回复设备的消息通过 `device:commands` 队列发回。
|
||||
//!
|
||||
//! 协议依据:`docs/柜控板固件对接文档.md`(v1.1)
|
||||
//! 协议依据:`docs/protocol/柜控板固件对接文档.md`(v1.1)
|
||||
//! - 上行报文**除 `auth_str` / `login` 外一律不带 `dev_id`**,设备身份由 device-server 注入;
|
||||
//! - 报文性质按**有无 `suc`** 判定:带 = 回执(平台**不再回包**),不带 = 设备请求(平台要回包);
|
||||
//! - 平台应答一律用 [`ServerResponse::success_for`] / [`ServerResponse::error_for`] 构造
|
||||
|
||||
@ -41,7 +41,7 @@ use tokio::sync::oneshot;
|
||||
/// (`auth_str` 是配网第一步,`login` 是握手本身);其余任何 `act` —— 含 `status_post`
|
||||
/// 等应答、`on`/`off` 业务指令、`open`/`pow_on`/`pow_off`/`reset` —— **一律不投递**。
|
||||
///
|
||||
/// 依据(`docs/柜控板固件对接文档.md` v1.1):§2.2「登录门控」、§10 第 10 条
|
||||
/// 依据(`docs/protocol/柜控板固件对接文档.md` v1.1):§2.2「登录门控」、§10 第 10 条
|
||||
/// (只有 `auth_str`/`login` 可以在未登录状态发送,其余 `act` 必须先 `login` 成功)、
|
||||
/// §4.1(上行同一规则)、§4.2(下发要求该 IMEI 存在**已登录的连接**,
|
||||
/// 设备也是在收到 `suc=1` 且含 `spec` 的应答后才算已登录)。
|
||||
@ -781,7 +781,7 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 登录门控(下行):`docs/柜控板固件对接文档.md` §2.2 / §4.1 / §10 第 10 条
|
||||
// 登录门控(下行):`docs/protocol/柜控板固件对接文档.md` §2.2 / §4.1 / §10 第 10 条
|
||||
// 「不管上行下行,同一条 TCP 连接 login 成功前只允许 auth_str / login」
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* 数据单位格式化工具
|
||||
*
|
||||
* 按 docs/API.md 数据单位规范:
|
||||
* 按 docs/tech/API.md 数据单位规范:
|
||||
* - soc: ‰(千分比)→ 百分比,÷10
|
||||
* - voltage: mV → V,÷1000
|
||||
* - current: mA → A,÷1000
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""TCP 协议端到端联调(模拟柜控板)——针对测试环境 device-server。
|
||||
|
||||
流程(严格按 docs/柜控板固件对接文档.md v1.1):
|
||||
流程(严格按 docs/protocol/柜控板固件对接文档.md v1.1):
|
||||
auth_str → login → status_post → bat_in → on(上报) → status_post×3(积分) → off
|
||||
|
||||
每一步都打印平台的应答,便于人工核对协议实现。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user