charging-cabinet/docs/tech/部署手册.md
12451 619c96c85f 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` 的「旧名 → 现名映射」表中给出对应关系。
2026-09-20 04:42:51 +08:00

375 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 部署手册(AGENTS.md 的详细参考篇)
> 本文件由 `AGENTS.md` 的「部署与维护」整段搬迁而来(**内容逐字保留,未删改**)。
> 搬迁原因:`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
```