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

18 KiB
Raw Blame History

部署手册(AGENTS.md 的详细参考篇)

本文件由 AGENTS.md 的「部署与维护」整段搬迁而来(内容逐字保留,未删改)。 搬迁原因:AGENTS.md 超过工作区指令预算(65,536 B)会被按头部截断,尾部不加载。

判据与最致命的几条已留在 AGENTS.md:改线上前先读那里的「部署前必看」+ 「部署注意事项」摘要;本文件给出完整步骤、nginx 全文、以及全部 11 条注意事项。


部署与维护

SSH连接

# 外网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 隧道):

# 先起隧道(另一个终端常驻)
pwsh tools/dev-tunnel.ps1

# 命令行
mysql -h 127.0.0.1 -P 13306 -u root -p
# 密码:Hbhyg731024@
# 测试环境库:pms_dev
# 正式环境库:pms
# 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 隧道):

# 命令行
redis-cli -h 127.0.0.1 -p 16379 -a "Hbhyg731024@"
# 测试环境:select 6
# 正式环境:select 5
# 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(需先起隧道):

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):

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):

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即可。

部署步骤

✅ 推荐用一键脚本(在本地开发机执行):

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. 本地编译

# 后端(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/ 下根本没有这个文件。

# 传输后端二进制(测试环境)
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{} 重复、行内注释等同类问题。)

    # 改线上前:先确认线上与仓库镜像一致(不一致就先搞清是谁改的)
    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):
      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:
      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. 服务器重启服务

# SSH到服务器
echo y | plink -ssh root@118.31.119.206 -pw "Hbhyg731024@"

# 正式环境
systemctl restart pms-api
systemctl restart pms-device

# 重启Nginx
systemctl restart nginx

服务管理

# 查看服务状态
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