diff --git a/AGENTS.md b/AGENTS.md index 9f38215..e16a7f6 100644 --- a/AGENTS.md +++ b/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 "" "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 "" "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=_zone:10m rate=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/ { - limit_req zone=_zone burst=N nodelay; - proxy_pass http://127.0.0.1:/api/; - ... - } - ``` - ⛔ **`limit_req` 引用的 zone 必须在主配置里存在**,否则 `nginx -t` 报 - `unknown limit_req_zone "_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,341 +932,98 @@ tail -f /var/log/nginx/error.log ## 数据模型 +> 📖 **逐列说明见 [docs/tech/数据模型.md](docs/tech/数据模型.md)**(由本节整段搬迁,内容逐字保留)。 +> **改任何表结构前必须读它**;本节只留改动前必须**先知道**的硬约束。 + ### 核心关系 ``` organization(组织/企业) ├── project(项目/放置地点) │ └── cabinet(充电柜) -│ ├── cabin_board(仓控板×N) board_id 1–15 -│ │ └── compartment(仓体×6) channel_id 1–8 -│ ├── charge_order(充电订单,进行中/已结算;active_flag 唯一键防并发) +│ ├── cabin_board(仓控板×N) board_id 1–15 +│ │ └── compartment(仓体×6) channel_id 1–8 +│ ├── 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`;
逐板逐通道 `device:{imei}:board:{b}:ch:{c}` 的 6 个字段;
`cabinet.status1` bit0=0 时**顺带把 `pow_fail_dc`/`pow_fail_ac` 清 0**(供电恢复);
并累加 `...: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 = 键不存在(等价于只返回存在的键)。
✅ **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` 转换,非业务浮点)。 ## 微信小程序用户端 -### 小程序配置 +> 📖 **完整篇见 [docs/tech/小程序用户端.md](docs/tech/小程序用户端.md)**(由本节整段搬迁,内容逐字保留)—— +> 页面清单、逐页职责、状态位分层展示口径、RSSI 分档都在那里。本节只留在改动前必须知道的硬约束。 -| 参数 | 值 | -|------|-----| -| 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 图标亮的 `` | -|------|---------|------|-----------|---------------------| -| ≥ 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 条 `` 按 `level > i` 决定亮几条;卡片头部数值颜色用同一个 `color`。 -- **小程序**(`home/index.vue`、`device-detail/index.vue`): - `v-for="(color, i) in rssiBarColors(cab.rssi)"` 渲染 `bars` 根 ``, - 颜色**内联** `: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. **通道统计**:图标+数字一行排列,图标用 `` 内联 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 详情页按钮(`` 包裹)+ - 小程序设备详情页按钮;**仓体(通道)详情页不放开门按钮**。 - - 权限码 `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. **`` 不支持 `display: flex`** — uni-app 的 `` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `` -2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠 -3. **内联 SVG + v-if**:Vue 3 支持在 `` 上用 `v-if` 条件渲染,适用于动态图标 -4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `` 上写死 +- **技术栈**: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 + node test/device_status_parse.test.mjs # 逐位遍历,断言 PC 与小程序口径一致 + node test/rssi_signal.test.mjs # 遍历 0–99,断言 level/bars/color 相同 + ``` +- **状态徽标的两个维度严格分离**:`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`)。 ## 代码审查豁免 diff --git a/README.md b/README.md index c653274..c948256 100644 --- a/README.md +++ b/README.md @@ -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-实时状态存储)。 ## 开发任务与质量约束 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..038a01d --- /dev/null +++ b/docs/README.md @@ -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` 末尾植入一行 + > `` → 命中;删除后 → 无输出。 + > ⚠️ 注意这些 `.md` 多为 **CRLF** —— 用脚本批量删除/替换时**必须按 `\r?\n` 处理**, + > 否则会漏改(我第一次就踩了:`\n` 正则配不上 `\r\n`,以为删干净了其实残留)。 +4. **改 `AGENTS.md` 的链接**:`AGENTS.md` 里所有 `docs/...` 链接都必须是**分类后**的路径 + (`docs/protocol/...`、`docs/tech/...`)。 +5. **别把权威内容只放进归档**:归档是留痕,规范要写进 `AGENTS.md` 或 `tech/`。 diff --git a/docs/审查报告-数据单位一致性-2026-07-06.md b/docs/archive/审查报告-数据单位一致性-2026-07-06.md similarity index 92% rename from docs/审查报告-数据单位一致性-2026-07-06.md rename to docs/archive/审查报告-数据单位一致性-2026-07-06.md index 70fff12..10b0807 100644 --- a/docs/审查报告-数据单位一致性-2026-07-06.md +++ b/docs/archive/审查报告-数据单位一致性-2026-07-06.md @@ -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 统一 ‰,与协议一致 diff --git a/docs/开发文档.md b/docs/archive/开发文档.md similarity index 100% rename from docs/开发文档.md rename to docs/archive/开发文档.md diff --git a/docs/项目深度分析报告-2026-07-06.md b/docs/archive/项目深度分析报告-2026-07-06.md similarity index 93% rename from docs/项目深度分析报告-2026-07-06.md rename to docs/archive/项目深度分析报告-2026-07-06.md index e4dce43..ab4d86d 100644 --- a/docs/项目深度分析报告-2026-07-06.md +++ b/docs/archive/项目深度分析报告-2026-07-06.md @@ -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 - 中优先级 diff --git a/docs/开发合同-充电柜-KF-06062401.docx b/docs/contract/开发合同-充电柜-KF-06062401.docx similarity index 100% rename from docs/开发合同-充电柜-KF-06062401.docx rename to docs/contract/开发合同-充电柜-KF-06062401.docx diff --git a/docs/开发合同.html b/docs/contract/开发合同.html similarity index 100% rename from docs/开发合同.html rename to docs/contract/开发合同.html diff --git a/docs/开发合同.md b/docs/contract/开发合同.md similarity index 100% rename from docs/开发合同.md rename to docs/contract/开发合同.md diff --git a/docs/开发合同.pdf b/docs/contract/开发合同.pdf similarity index 100% rename from docs/开发合同.pdf rename to docs/contract/开发合同.pdf diff --git a/docs/附件-开发需求规格说明书.html b/docs/contract/附件-开发需求规格说明书.html similarity index 100% rename from docs/附件-开发需求规格说明书.html rename to docs/contract/附件-开发需求规格说明书.html diff --git a/docs/附件-开发需求规格说明书.pdf b/docs/contract/附件-开发需求规格说明书.pdf similarity index 100% rename from docs/附件-开发需求规格说明书.pdf rename to docs/contract/附件-开发需求规格说明书.pdf diff --git a/docs/附件一-硬件规格.docx b/docs/contract/附件一-硬件规格.docx similarity index 100% rename from docs/附件一-硬件规格.docx rename to docs/contract/附件一-硬件规格.docx diff --git a/docs/附件一-硬件规格.md b/docs/contract/附件一-硬件规格.md similarity index 100% rename from docs/附件一-硬件规格.md rename to docs/contract/附件一-硬件规格.md diff --git a/docs/附件三-交付与验收标准.docx b/docs/contract/附件三-交付与验收标准.docx similarity index 100% rename from docs/附件三-交付与验收标准.docx rename to docs/contract/附件三-交付与验收标准.docx diff --git a/docs/附件三-交付与验收标准.md b/docs/contract/附件三-交付与验收标准.md similarity index 100% rename from docs/附件三-交付与验收标准.md rename to docs/contract/附件三-交付与验收标准.md diff --git a/docs/附件二-软件规格.docx b/docs/contract/附件二-软件规格.docx similarity index 100% rename from docs/附件二-软件规格.docx rename to docs/contract/附件二-软件规格.docx diff --git a/docs/附件二-软件规格.md b/docs/contract/附件二-软件规格.md similarity index 100% rename from docs/附件二-软件规格.md rename to docs/contract/附件二-软件规格.md diff --git a/docs/1TN协议标准_YG20240118-1_V1.0.md b/docs/protocol/1TN协议标准_YG20240118-1_V1.0.md similarity index 100% rename from docs/1TN协议标准_YG20240118-1_V1.0.md rename to docs/protocol/1TN协议标准_YG20240118-1_V1.0.md diff --git a/docs/1TN协议标准_苏州翌工电源_V1.0.pdf b/docs/protocol/1TN协议标准_苏州翌工电源_V1.0.pdf similarity index 100% rename from docs/1TN协议标准_苏州翌工电源_V1.0.pdf rename to docs/protocol/1TN协议标准_苏州翌工电源_V1.0.pdf diff --git a/docs/柜控板固件对接文档.md b/docs/protocol/柜控板固件对接文档.md similarity index 100% rename from docs/柜控板固件对接文档.md rename to docs/protocol/柜控板固件对接文档.md diff --git a/docs/API.md b/docs/tech/API.md similarity index 100% rename from docs/API.md rename to docs/tech/API.md diff --git a/docs/tech/小程序用户端.md b/docs/tech/小程序用户端.md new file mode 100644 index 0000000..41ba709 --- /dev/null +++ b/docs/tech/小程序用户端.md @@ -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 图标亮的 `` | +|------|---------|------|-----------|---------------------| +| ≥ 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 条 `` 按 `level > i` 决定亮几条;卡片头部数值颜色用同一个 `color`。 +- **小程序**(`home/index.vue`、`device-detail/index.vue`): + `v-for="(color, i) in rssiBarColors(cab.rssi)"` 渲染 `bars` 根 ``, + 颜色**内联** `: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. **通道统计**:图标+数字一行排列,图标用 `` 内联 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 详情页按钮(`` 包裹)+ + 小程序设备详情页按钮;**仓体(通道)详情页不放开门按钮**。 + - 权限码 `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. **`` 不支持 `display: flex`** — uni-app 的 `` 组件是纯文本组件,不接受 flex 布局。需要 flex 的地方用 `` +2. **SVG 图标居中**:`.stat-chip svg { display: block; flex-shrink: 0 }` 用 CSS 统一控制尺寸,比内联属性更可靠 +3. **内联 SVG + v-if**:Vue 3 支持在 `` 上用 `v-if` 条件渲染,适用于动态图标 +4. **CSS 覆盖 SVG 尺寸**:用 `.xxx svg { width: Xpx; height: Xpx }` 统一控制图标大小,不在每个 `` 上写死 diff --git a/docs/tech/数据模型.md b/docs/tech/数据模型.md new file mode 100644 index 0000000..28468ba --- /dev/null +++ b/docs/tech/数据模型.md @@ -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`;
逐板逐通道 `device:{imei}:board:{b}:ch:{c}` 的 6 个字段;
`cabinet.status1` bit0=0 时**顺带把 `pow_fail_dc`/`pow_fail_ac` 清 0**(供电恢复);
并累加 `...: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 = 键不存在(等价于只返回存在的键)。
✅ **2026-09 已修**:旧实现用 `KEYS device:{imei}:board:*:ch:*`,在**与既有充电站平台共用**的 Redis 实例上会阻塞单线程(实测每次请求 `cmdstat_keys` +1),已彻底移除 | + +> ⛔ **本项目禁止在任何请求路径上用 `KEYS`** —— Redis 实例是共用的,`KEYS` 会阻塞单线程遍历整个 +> keyspace。要枚举就用「按 DB 拓扑拼 key」或 `SCAN` 游标。查是否有人用了: +> `grep -rn '"KEYS"' software/*/src/`。 diff --git a/docs/tech/部署手册.md b/docs/tech/部署手册.md new file mode 100644 index 0000000..f50c5af --- /dev/null +++ b/docs/tech/部署手册.md @@ -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 "" "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 "" "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=_zone:10m rate=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/ { + limit_req zone=_zone burst=N nodelay; + proxy_pass http://127.0.0.1:/api/; + ... + } + ``` + ⛔ **`limit_req` 引用的 zone 必须在主配置里存在**,否则 `nginx -t` 报 + `unknown limit_req_zone "_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 +``` diff --git a/software/api-server/src/db/migrate.rs b/software/api-server/src/db/migrate.rs index b02ce77..f56156c 100644 --- a/software/api-server/src/db/migrate.rs +++ b/software/api-server/src/db/migrate.rs @@ -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", diff --git a/software/api-server/src/routes/cabinets.rs b/software/api-server/src/routes/cabinets.rs index cfb1beb..064003c 100644 --- a/software/api-server/src/routes/cabinets.rs +++ b/software/api-server/src/routes/cabinets.rs @@ -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 { diff --git a/software/api-server/src/workers/report_worker.rs b/software/api-server/src/workers/report_worker.rs index 58072f7..6aa492c 100644 --- a/software/api-server/src/workers/report_worker.rs +++ b/software/api-server/src/workers/report_worker.rs @@ -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`] 构造 diff --git a/software/device-server/src/tcp/connection.rs b/software/device-server/src/tcp/connection.rs index 5e8ee8d..bfd1f4a 100644 --- a/software/device-server/src/tcp/connection.rs +++ b/software/device-server/src/tcp/connection.rs @@ -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」 // ----------------------------------------------------------------------- diff --git a/software/h5-uniapp/src/utils/format.ts b/software/h5-uniapp/src/utils/format.ts index 8b870f2..db9bd51 100644 --- a/software/h5-uniapp/src/utils/format.ts +++ b/software/h5-uniapp/src/utils/format.ts @@ -1,7 +1,7 @@ /** * 数据单位格式化工具 * - * 按 docs/API.md 数据单位规范: + * 按 docs/tech/API.md 数据单位规范: * - soc: ‰(千分比)→ 百分比,÷10 * - voltage: mV → V,÷1000 * - current: mA → A,÷1000 diff --git a/test/tcp_e2e_smoke.py b/test/tcp_e2e_smoke.py index 88f9540..4aec4d6 100644 --- a/test/tcp_e2e_smoke.py +++ b/test/tcp_e2e_smoke.py @@ -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 每一步都打印平台的应答,便于人工核对协议实现。