fix(test)+fix(deploy)+docs: 探针状态语义反转修复、退出码补齐、文档校订
1) probe_export.py:任务状态语义**搞反了**(导出成功也会被判失败)
- download_tasks.status 的权威语义是 0 排队 / 1 处理中 / **2 完成** / 3 失败
(db/migrate.rs:225 列注释;写入侧 charge_records.rs::mark_task_completed 写的就是 2)。
- 该脚本此前**等 status == 1(处理中)当成功、把 status == 2(完成)判成"导出失败"**
—— 典型假红。已按权威语义订正,并与另外两处**本来就正确**的口径对齐:
web/src/pages/charge-records/DownloadCenter.tsx::statusText、
test/schema_alignment_e2e.py(等 status in (2,3) 再断言 == 2)。
- 同批修正退出码分类:账号缺 charge_record:export(403)由 exit 1 改为
**exit 2「未验证」** —— 本项要验证的是"降权成 pms 后能否往 exports/ 落盘",
没权限根本走不到那一步,报 1 是**把夹具缺陷说成产品缺陷**(误报与假绿同样有害)。
- 允许用 PMS_PROBE_PHONE / PMS_PROBE_PASSWORD 换成有权限的账号。
- **C 项结论(由落盘物证直接判定,不依赖该脚本)**:测试环境
exports/充电记录__-.xlsx 5559 B、生产同类产物 5457 B,属主均为 pms:pms;
两环境 exports/uploads 目录均 pms:pms,两 api-server 单元均 User=pms/Group=pms
⇒ 降权进程确实能落盘。已在 README 写明并给出取证依据。
2) deploy-prod.ps1 的退出码(此前失败的探针只打印 ❌ 就继续,脚本仍以 0 退出)
- 消费链路探测失败(exit 1)现在**记入失败清单**并以 exit 1 收尾;
"未验证"(隧道没起)默认仍不计失败(环境问题会误报),
但新增 **-StrictVerify** 开关供 CI/无人值守使用,打开即 exit 1。
- 好处:把这条从"打印了 ❌ 却 exit 0"(仓库第 8 条假绿)里摘出来。
3) 统一退出码 0/1/2 落地到本批脚本,并对每个失败路径实测取证:
- test/tcp_e2e_smoke.py:13/13 通过(含未登录业务报文被静默丢弃);先复位夹具再跑。
- test/security-hardening/ 新增 verdict.py 判据容器(未跟踪 → 本次入库);
4 个探针全部按失败→exit 1 改造,并各实测过失败路径。
- 实测结果(2026-09-20 发布后回归):smoke_prod 4/4 exit 0、
probe_prod 5/5 exit 0、probe_upload 6/6 exit 0。
4) 工具与文档
- tools/db.py 默认库由**生产 pms 改为测试 pms_dev**(可用 PMS_MYSQL_DB 覆盖,
切生产时打印醒目警告)—— "以为在测测试库、实际打生产"的隐患。
- tools/db_schema_diff.py:连不上必须是 exit 2,绝不伪装成"无漂移"的 0。
- 新增 tools/dev-tunnel.ps1(入库,SSH 隧道;口令走 PMS_SSH_PASSWORD 环境变量),
配套 tools/dev-tunnel-run.ps1 自愈启动器**保持 gitignore**(内含口令)。
- deploy/nginx/nginx.conf 镜像随线上同步(移除 stream 转发;与线上逐字节一致)。
- AGENTS.md / README.md:补「踩过的坑」第 7/8 条(掐断上游管道会杀被测进程、
打印 ❌ 却 exit 0 是假绿)、退出码约定、本地开发改 SSH 隧道、
在线判据口径订正、order_timeout 守门补齐、小程序 vue-tsc 门禁与其抓到的两个真 BUG。
发布验收(本次统一发布,按 device→api 顺序):
- 测试环境与生产环境四服务 active,端口 9000/9001/3000/3001 正确,ERROR 均为 0;
- 生产迁移按判据 grep '更新列注释' 实测对齐 cabinets.status 列注释;
- 结构漂移 python tools/db_schema_diff.py = 0 处漂移 / exit 0(--vs-ddl 两库同样 0);
- schema_alignment_e2e --env dev 全部通过 exit 0;
- 生产域名 /api/pms/cabinets -> 401、/pms/ -> 200;消费链路探测通过。
This commit is contained in:
parent
aa02cd4e58
commit
471b61d277
1
.gitignore
vendored
1
.gitignore
vendored
@ -61,3 +61,4 @@ tmp/
|
||||
deploy_output.txt
|
||||
deploy_remote.sh
|
||||
/tmp/
|
||||
tools/dev-tunnel-run.ps1
|
||||
|
||||
247
AGENTS.md
247
AGENTS.md
@ -35,7 +35,8 @@ D:\charging-cabinet\
|
||||
│ ├── deploy-prod.ps1 # ✅ 正式环境一键部署(推荐入口)
|
||||
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出,见「部署步骤」)
|
||||
├── tools/ # 开发工具
|
||||
│ ├── db.py # 数据库工具(默认连测试库 pms_dev)
|
||||
│ ├── db.py # 数据库工具(默认连测试库 pms_dev;需先起 SSH 隧道)
|
||||
│ ├── dev-tunnel.ps1 # 开发机 SSH 隧道(127.0.0.1:13306/16379 → 阿里云 RDS/Redis)
|
||||
│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL)
|
||||
├── software/
|
||||
│ ├── api-server/ # Rust API服务(产物 pms-api)
|
||||
@ -91,7 +92,8 @@ D:\charging-cabinet\
|
||||
- **ZeroTier**: 网络ID `63e843dd42f54b9c`,服务器节点 `12a91dd47d`,IP `10.8.0.252`
|
||||
- **本地电脑ZeroTier IP**: `10.8.0.248`
|
||||
- **已装软件**: Nginx 1.20.1、ZeroTier 1.16.2、socat(开机自启)
|
||||
- **服务**: mysql-proxy(3306→RDS)、redis-proxy(6379→Redis)、Gitea(3080)、acme.sh
|
||||
- **服务**: Gitea(3080)、acme.sh
|
||||
(⛔ 曾经的 `mysql-proxy(3306→RDS)`、`redis-proxy(6379→Redis)` 一律**已移除**,见「本地开发环境」)
|
||||
- **不装**: Docker/Node/Rust(本地编译传二进制)
|
||||
- **SSH**: plink连接,首次需 `echo y` 接受host key
|
||||
|
||||
@ -102,36 +104,83 @@ D:\charging-cabinet\
|
||||
|
||||
### 本地开发环境
|
||||
|
||||
**数据库连接(通过ZeroTier):**
|
||||
**数据库连接(通过 SSH 隧道):**
|
||||
|
||||
服务器 nginx 的 `stream{}` 转发(把 3306/6379 转到阿里云 RDS/Redis)**已于 2026-09-20 移除**:
|
||||
|
||||
- `10.8.0.252:3306` / `10.8.0.252:6379`(含 `127.0.0.1` 回环监听)**现在完全不可连**(实测 ✅);
|
||||
- 因此**本地/开发侧一律改走 SSH 隧道** —— `tools/dev-tunnel.ps1`:
|
||||
|
||||
```bash
|
||||
# ① 手工起隧道(前台常驻一个窗口;需先设 PMS_SSH_PASSWORD = 服务器 SSH 口令)
|
||||
$env:PMS_SSH_PASSWORD = '<服务器 SSH 口令>'
|
||||
pwsh tools/dev-tunnel.ps1
|
||||
# = plink -N -L 13306:<阿里云MySQL>:3306 -L 16379:<阿里云Redis>:6379 root@118.31.119.206
|
||||
|
||||
# ② 常驻自愈启动器(推荐日常用):断了 5 秒后自动重连
|
||||
pwsh tools/dev-tunnel-run.ps1
|
||||
```
|
||||
MySQL: 10.8.0.252:3306
|
||||
|
||||
**隧道两个脚本的分工(换机/重装后照这个重建):**
|
||||
|
||||
| 文件 | 作用 | 是否入库 |
|
||||
|------|------|----------|
|
||||
| `tools/dev-tunnel.ps1` | 建隧道本体(`plink -N -L …`);默认 `127.0.0.1:13306`(MySQL) / `127.0.0.1:16379`(Redis),口令从 `$env:PMS_SSH_PASSWORD` 读 | ✅ 入库 |
|
||||
| `tools/dev-tunnel-run.ps1` | **自愈启动器**:`while($true){ 跑 dev-tunnel.ps1; sleep 5 }`,**内含口令** | ⛔ **已 gitignore,勿入库** |
|
||||
|
||||
- 日常已把 `dev-tunnel-run.ps1` 的快捷方式放进 **Windows 启动文件夹**
|
||||
(`%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\PMS-DEV-TUNNEL.lnk`)→ **登录即起、断线自愈**;
|
||||
它的输出追加到 `%TEMP%\pms-dev-tunnel.log`(**排查时先看这个日志**)。
|
||||
- **换机 / 重装后**:设好 `PMS_SSH_PASSWORD` 跑一次 `pwsh tools/dev-tunnel.ps1` 即可;
|
||||
要恢复自愈+开机自启,就在启动文件夹放一个指向 `tools/dev-tunnel-run.ps1`
|
||||
(`pwsh -NoProfile -WindowStyle Hidden -File <仓库>\tools\dev-tunnel-run.ps1`)的快捷方式。
|
||||
- 端口/主机名**只在 `dev-tunnel.ps1` 的 `param` 里定义一份**(默认与各脚本的环境变量默认值一致),
|
||||
别在别处再写第二套默认值。
|
||||
- ⚠️ 该脚本历史上有个"**日志骗人**"的坑(已修):`"... -> $MySqlHost:3306"` 被 PowerShell 当成
|
||||
`$作用域:名字` 的变量,主机名渲染成空串(隧道其实正常)。变量后紧跟 `:` 必须写 `"${MySqlHost}:3306"`。
|
||||
|
||||
```text
|
||||
MySQL: 127.0.0.1:13306 (经隧道 → rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306)
|
||||
user: root
|
||||
password: Hbhyg731024@
|
||||
database: pms_dev (新建,不动AZZCWeChat*)
|
||||
database: pms_dev (测试库) / pms (正式库)
|
||||
|
||||
Redis: 10.8.0.252:6379
|
||||
Redis: 127.0.0.1:16379 (经隧道 → r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379)
|
||||
password: Hbhyg731024@
|
||||
db: 6 (测试环境用db6,不动db1-5)
|
||||
db: 6 (测试环境用db6,不动db1-5) / 5 (正式)
|
||||
```
|
||||
|
||||
**代理说明:**
|
||||
- 服务器nginx stream转发MySQL(3306)和Redis(6379)到阿里云RDS ——
|
||||
⚠️ **这个转发只为「本地开发机经 ZeroTier 访问阿里云」而存在**(监听 `10.8.0.252:3306/6379` 等)。
|
||||
**服务侧(生产 `/data/pms`、测试 `/data/pms_dev`)一律直连阿里云**:
|
||||
所有本地脚本(`tools/db.py`、`tools/db_schema_diff.py`、`test/*.py`)**默认就指向这两个隧道端口**,
|
||||
可用环境变量覆盖(无需改代码):`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / `PMS_MYSQL_DB`、
|
||||
`PMS_REDIS_HOST` / `PMS_REDIS_PORT`(`db.py` 默认库 = **测试库 `pms_dev`**)。
|
||||
⚠️ 隧道没起时,脚本会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 **exit 2**
|
||||
(**连不上绝不会被当成"无漂移/无问题"**)。
|
||||
|
||||
**为什么服务侧直连阿里云(2026-09-20 已改,勿回退):**
|
||||
|
||||
- 走 nginx 时 `systemctl reload nginx` 会切断所有经它转发的连接 → 四个服务同时
|
||||
`RPOP 错误: broken pipe`(实测每次 reload 4 条 ERROR);
|
||||
- 更要紧的是**生产的库/缓存通路会依赖一个本来只给开发用的转发层**
|
||||
(`stream{}` 一旦配错 nginx 起不来 → 整平台连不上库)。
|
||||
- 服务侧(生产 `/data/pms`、测试 `/data/pms_dev`)一律直连:
|
||||
`DATABASE_URL=...@rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306/...`、
|
||||
`REDIS_URL=...@r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379/...`
|
||||
(2026-09-20 已改;改前生产走的是 `127.0.0.1:6379/3306` —— 那两个 **nginx 监听**的回环端口)。
|
||||
**为什么必须直连**:走 nginx 时 `systemctl reload nginx` 会切断所有经它转发的连接 → 四个服务同时
|
||||
`RPOP 错误: broken pipe`(实测每次 reload 4 条 ERROR);更要紧的是**生产的库/缓存通路会依赖一个
|
||||
本来只给开发用的转发层**(`stream{}` 一旦配错 nginx 起不来 → 整平台连不上库)。
|
||||
`REDIS_URL=...@r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379/...`。
|
||||
改造后实测:reload nginx 前后生产连接数不变(5→5)、生产与测试 ERROR 均为 0。
|
||||
`127.0.0.1` 的 stream 监听**保留**(无副作用,本地开发/回环排查仍可用),但**服务不再用它**
|
||||
- systemd自启,`Restart=always` 自动恢复
|
||||
- 本地直连 `10.8.0.252`,无需SSH隧道
|
||||
- ⛔ **服务器不再开放 3306/6379**(ZeroTier 上也连不到了);要给开发机开库通路就用 SSH 隧道,
|
||||
**不要在服务器上重新暴露库端口**(见「部署注意事项」第 6 条)。
|
||||
- 设备 TCP 业务端口(`:9001` / `:3001`)**不受影响**,仍按原样可达(设备走公网连它)。
|
||||
- systemd 自启,`Restart=always` 自动恢复。
|
||||
|
||||
### 数据库工具
|
||||
|
||||
本地可直接访问数据库(通过ZeroTier),使用 `tools/db.py`:
|
||||
⚠️ **需先起隧道**:`pwsh tools/dev-tunnel.ps1`(默认连 `127.0.0.1:13306`,见上节)。
|
||||
连接参数可用环境变量覆盖:`PMS_MYSQL_HOST` / `PMS_MYSQL_PORT` / **`PMS_MYSQL_DB`**;
|
||||
`db_schema_diff.py` 的 `--host/--port` **优先级更高**(命令行 > 环境变量 > 默认隧道端口)。使用 `tools/db.py`:
|
||||
|
||||
> **`tools/db.py` 的库口径(2026-09-20 定稿)**:默认 **`pms_dev`(测试库)**,可用 `PMS_MYSQL_DB` 覆盖;
|
||||
> 每次连接前都会打印 `当前目标库:xxx(可用 PMS_MYSQL_DB 覆盖)`,
|
||||
> **若取 `pms`(生产库)会额外打印 `⚠️ 你正在操作正式库 pms …`**。
|
||||
> (此前代码默认写死 `pms`、文档却写 `pms_dev` —— "以为在测测试库、实际打生产"的隐患,已按默认测试库修正。)
|
||||
|
||||
```bash
|
||||
# 列出所有表
|
||||
@ -150,7 +199,7 @@ python tools/db.py exec "UPDATE users SET status=1 WHERE id=1"
|
||||
python tools/db.py import data.sql
|
||||
```
|
||||
|
||||
**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次)**:
|
||||
**结构漂移检查(只读,建议每次改动表结构/迁移后跑一次;同样需先起隧道)**:
|
||||
|
||||
```bash
|
||||
# 测试库 vs 生产库 全列比对(类型/可空性/默认值/EXTRA/注释 + TIMESTAMP 巡检)
|
||||
@ -161,6 +210,8 @@ python tools/db_schema_diff.py --vs-ddl
|
||||
python tools/db_schema_diff.py --db pms --vs-ddl
|
||||
```
|
||||
退出码:`0`=无漂移、`1`=发现漂移(可接进部署前置检查)、`2`=参数/连接错误。
|
||||
⛔ **连不上时必须是 `2`,绝不能当成"无漂移"的 `0`**(隧道没起就跑,会明确报
|
||||
「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」)。
|
||||
> 为什么必须有它:"测试库与生产结构不一致"已经栽了 5 次(BRPOP 卡死、`iccid` NULL、
|
||||
> `SCHEMA_SQL` 前向引用、测试环境导出 500、生产 `/api/charge-orders` 500),
|
||||
> 每次都是靠人肉发现。`CREATE TABLE IF NOT EXISTS` 对已存在的表**不改列定义**,
|
||||
@ -204,7 +255,8 @@ python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-p
|
||||
|
||||
- **禁止修改设备与TCP服务的通信协议** — 设备端固件不可改,TCP 报文 JSON 格式、字段名、act 类型、签名机制(SHA256取8位)全部冻结;所有修复必须是服务端内部逻辑变更,设备侧零感知
|
||||
- **禁止在服务器上编译Rust** — 2核/3.5G会OOM,本地编译传二进制
|
||||
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存,用nginx stream代理
|
||||
- **禁止使用socat fork模式** — 进程泄漏会耗尽内存;服务侧一律**直连阿里云**,
|
||||
开发机要连库走 **SSH 隧道**(`tools/dev-tunnel.ps1`),⛔ 不要再在服务器上做端口转发
|
||||
- **禁止提交密码/密钥到仓库** — 使用环境变量或本地配置文件(已gitignore)
|
||||
|
||||
### 编码任务下发
|
||||
@ -270,6 +322,20 @@ node test/rssi_signal.test.mjs
|
||||
> ⚠️ **验收命令必须先自证能失败**:写完一条"通过"的验收命令,**故意造一个该命令应当抓到的缺陷,确认它真的报错**,
|
||||
> 再撤销缺陷。否则你拿到的绿灯可能是"什么都没检查"(`tsc --noEmit` 就是这么假绿的,见「踩过的坑」第 2 条)。
|
||||
|
||||
**退出码约定(所有验收/工具脚本统一,2026-09-20 定稿)**:
|
||||
|
||||
| 退出码 | 含义 |
|
||||
|--------|------|
|
||||
| `0` | 全部通过 |
|
||||
| `1` | **断言/判据失败**(必须把失败项额外打到 stderr) |
|
||||
| `2` | **环境/连接问题**(隧道没起、DB/Redis/后端连不上、缺 `.env`) |
|
||||
|
||||
⛔ **适用于本仓每一个会被自动化调用的脚本**(`tools/*.py`、`test/*.py`、`test/*.mjs`、`deploy/*.ps1`、`*.sh`):
|
||||
**"失败时退出码非 0"是硬性验收项 —— 上线前必须像上面那样自证一遍**(故意造缺陷 → 确认非 0 → 撤销)。
|
||||
只打印 `❌`/`合计 N/M` 而不设退出码 = **假绿**(`test/tcp_e2e_smoke.py` 曾长期如此,见「踩过的坑」第 7/8 条);
|
||||
探针脚本若无明确判据,必须**显式声明"纯取证、不可作判据"**,不许默默 `exit 0`。
|
||||
现象阈值拿不准的(如限流第几次触发)**不要写成判据**,保持观察输出 —— **误报与假绿同样有害**。
|
||||
|
||||
---
|
||||
|
||||
## 踩过的坑(历史教训,改动前必读)
|
||||
@ -295,9 +361,16 @@ node test/rssi_signal.test.mjs
|
||||
做**双向断言**(刚建 5 分钟不命中 / 超 10 小时 1 分钟命中 / 差 1 分钟不命中),
|
||||
并**反证**"写反的方向必然命中刚建单",保证方向一反**一定红**。
|
||||
已实测:把 SQL 改成 `TIMESTAMPADD(HOUR, 10, NOW())` 后该用例**立即 FAILED**。
|
||||
⚠️ 遗留:`workers/order_timeout.rs` 的扫描条件是**另一种等价写法**
|
||||
(`TIMESTAMPADD(HOUR, ?, start_time) < NOW()`),该文件不在本次改动范围内,
|
||||
✅ **两处现已都有守门测试(2026-09-20 补齐,勿再退回)**:
|
||||
- `workers/report_worker.rs`:常量 `FINALIZE_EXPIRED_ORDER_SQL` + `test_expired_order_condition_points_backwards`
|
||||
+ `test_is_expired_two_way_assertions_catch_reversed_direction`;
|
||||
- `workers/order_timeout.rs`:常量 `EXPIRED_ORDER_SCAN_SQL` + 同名两个守门用例
|
||||
(此前只在文档里留了「遗留」标记 —— 那边再被改反也一样零报错、单测全绿,属漏网)。
|
||||
**两处是等价但写法不同的判据**(前者把阈值往回绑**负数**,后者把 `start_time` **往前推**),
|
||||
**改动任一处都要同时确认另一处**。
|
||||
已实测(2026-09-20):把 `order_timeout.rs` 的常量反写成
|
||||
`start_time < TIMESTAMPADD(HOUR, ?, NOW())` 后 → `test_expired_order_scan_sql_points_backwards`
|
||||
**立即 FAILED、cargo test exit 101**;还原后 3 passed / exit 0。
|
||||
|
||||
### 2. 验收命令必须先自证能失败:`tsc --noEmit` 在 solution 型 tsconfig 下是空跑假绿
|
||||
|
||||
@ -308,9 +381,21 @@ node test/rssi_signal.test.mjs
|
||||
`src/probe.ts(2,14): error TS2322: Type 'string' is not assignable to type 'number'.`、**exit 2**。
|
||||
- **正确命令**:`cd software/web && npm run typecheck`(`package.json` 里就是 `tsc -b`)。
|
||||
`tsconfig.app.json` 已设 `noEmit: true`,`tsc -b` 不会吐 JS 产物。
|
||||
- `software/h5-uniapp/tsconfig.json` 是**普通** tsconfig(有 `include`),`tsc --noEmit` 在那里
|
||||
是有效的;但该工程**没有 typecheck 脚本**,且 `tsc` **不检查 `.vue` 文件**,小程序端的真实
|
||||
类型安全目前靠构建 + `node test/device_status_parse.test.mjs`。
|
||||
- **小程序(`software/h5-uniapp`)的 typecheck 是真门禁(2026-09-20 新增)**:
|
||||
`cd software/h5-uniapp && npm run typecheck`(= **`vue-tsc --noEmit -p tsconfig.json`**)。
|
||||
- ⛔ **不能用 `tsc --noEmit` 代替**:`tsc` **根本不检查 `.vue` 文件**,而本工程 9 个页面全是 `.vue`
|
||||
⇒ 只跑 `tsc` 等于把页面逻辑整个漏掉(与 solution-tsconfig 空跑同类的假绿)。
|
||||
- 必须用 `vue-tsc`(已作为 devDependency 装入,`package.json` 里有 `typecheck` 脚本)。
|
||||
- **接入时立刻抓到 17 个真实错误**(此前完全没人发现),其中两个是**真 BUG**:
|
||||
① `me/index.vue` 写了 `user.value`,而 Pinia setup store 对外已解包 ⇒ `user.value` 恒 `undefined`,
|
||||
**头像上传成功后写回 store 的那行是空操作**(要等冷启动才显示新头像);同时 `const user = authStore.user`
|
||||
是**快照**、丢掉响应式。已改为 `computed(() => authStore.user)`;
|
||||
② `CompartmentDetail` 类型**漏了后端确实会返回的 `temp`**(`routes/h5.rs` 的
|
||||
`COMPARTMENT_REALTIME_FIELDS` 第 6 项,有单测守门)⇒ 页面读 `data.temp` 报 TS2339。已补。
|
||||
其余 12 处是宿主全局 `wx` 未声明(`@dcloudio/types` 只声明跨端 `uni`),已在
|
||||
`src/env.d.ts` 里**只声明实际用到的两个 API**(刻意不写 `[k: string]: any`,否则等于关掉这层检查)。
|
||||
- 已按仓库铁律**自证能失败**:植入 `export const probe: number = 'x'` → **exit 2**;
|
||||
撤销 → **exit 0**。
|
||||
|
||||
### 3. MySQL 的 `TIMESTAMP` 列与 sqlx `NaiveDateTime` **不兼容**(只与 `DateTime<Utc>` 兼容)
|
||||
|
||||
@ -356,6 +441,32 @@ node test/rssi_signal.test.mjs
|
||||
- 记录:本次已清理 db5 的 462 个旧键,保留 `auth_str*` / `pending:*` / `device:reports` /
|
||||
`device:commands*` / `node:*`(db6 无残留)。清理后 `/api/cabinets/filter` 的在线台数 **6 → 0**。
|
||||
|
||||
### 7. **验收取值别用会掐断上游管道的方式** —— `| Select-Object -First N` 会杀掉被测进程
|
||||
|
||||
- **实测(2026-09-20,我踩的)**:为看 `test/tcp_e2e_smoke.py` 的开头几行,写成
|
||||
`python test/tcp_e2e_smoke.py | Select-Object -First 8`。PowerShell 拿满 8 个对象后**立即停止上游管道**
|
||||
⇒ **python 被中途掐死**:脚本只跑到第 ② 步,既没跑完断言,也**没走清理/复位**,夹具被留在半路状态。
|
||||
紧接着我又跑了一次"取最后 3 行"的命令,于是看到 **2/13**,差点当成"隧道改造引入了回归"。
|
||||
- **为什么危险**:这类假信号**看起来像被测对象坏了**,会把人引向完全错误的排查方向;
|
||||
更糟的是它**污染了夹具**(本次:`auth_str` 已签发未复位),使后续所有观测都不可信 ——
|
||||
"测量工具本身改变了被测对象"。
|
||||
- **规范**:
|
||||
1. 要截取输出就**先落盘再截**:`python x.py *> $env:TEMP\out.txt` 然后 `Get-Content out.txt | Select-Object -First N`
|
||||
(`*> file` 不会掐断上游;`Select-Object -Last N` 也不会,但统一落盘最省心);
|
||||
2. 看到"突然变红"先自问:**是不是我的取值方式改变了被测对象?**(进程被杀 / 夹具被留在中间态);
|
||||
3. 复现任何"红"之前先**把环境复位到干净态**(如先 `python test/reset_fixture.py`)再跑 ——
|
||||
`test/tcp_e2e_smoke.py` 有前置条件:**必须先复位夹具**,否则第 ① 步 `auth_str already issued` 会连锁失败(2/13)。
|
||||
|
||||
### 8. **"打印了 ❌ 却 exit 0" 是假绿** —— 脚本不设退出码,自动化就把失败当通过
|
||||
|
||||
- **实测(2026-09-20)**:`test/tcp_e2e_smoke.py` 结尾只 `print("合计 N/13 通过")`,**没有 `sys.exit`**
|
||||
⇒ 断言 2/13 失败时 **退出码仍是 0**;`test/tcp_latency_probe.py` 打印 `❌ 75s 内无应答` 也是 exit 0;
|
||||
`test/security-hardening/` 下 4 个探针(`probe_upload` / `probe_prod` / `probe_bruteforce` / `smoke_prod`)
|
||||
结尾写死 `return 0`。这些与 `tsc --noEmit` 空跑**同属一类假绿**,且**一直被当成"✅ 通过"写进验收报告**。
|
||||
- **规范**:见「验收流程」的**退出码约定(0/1/2)**——每个脚本都要"故意造缺陷 → 确认非 0 → 撤销"。
|
||||
探针类脚本共用 `test/security-hardening/verdict.py`(判据容器 + 统一退出码),
|
||||
判据只写明确可复现的期望("应被拒" = `status != 200`),拿不准的保持观察输出。
|
||||
|
||||
---
|
||||
|
||||
## Rust 编码规范
|
||||
@ -372,8 +483,8 @@ node test/rssi_signal.test.mjs
|
||||
- 使用 `tokio` 运行时,数据库/Redis 操作用 `.await`
|
||||
- 长时间任务用 `tokio::spawn` 异步处理,不阻塞主循环
|
||||
- Redis 队列消费:**禁止用 `BRPOP key 0`(无限阻塞),也不要依赖阻塞命令的"即时唤醒"** ——
|
||||
本项目 Redis 经 nginx stream 代理访问阿里云 RDS,客户端又是多路复用的 `ConnectionManager`,
|
||||
这种组合下 `BRPOP` 语义不可靠(实测 2026-09):
|
||||
本项目 Redis 此前经 nginx stream 代理访问阿里云(**该转发已于 2026-09-20 移除,现为直连**),
|
||||
客户端又是多路复用的 `ConnectionManager`,那种组合下 `BRPOP` 语义不可靠(实测 2026-09):
|
||||
- `BRPOP key 0` → 代理掐掉空闲连接后**消费永久卡死**,进程活着、HTTP 正常、**零错误日志**;
|
||||
- `BRPOP key N` → **"超时才醒"而非"数据到达即醒"**,超时设多大延迟就至少多大(实测下行指令 15~45 秒)。
|
||||
**规范做法**:`RPOP` 非阻塞轮询 + 空队列时 `sleep` 200ms(生产者 `LPUSH`,FIFO 一致),
|
||||
@ -559,7 +670,17 @@ test_check_permission_user_returns_forbidden
|
||||
依据:`docs/柜控板固件对接文档.md` §2.2 / §10 第 10 条(L1116)/ §4.1(L372)。
|
||||
**判据是「连接级认证标志」**(`device-server` 的 `is_authenticated(dev_id, conn_id)`,
|
||||
在投递到该连接的 `login` 成功应答——含 `spec` + `suc=1`——时置位),
|
||||
**不是** Redis 在线键 `device:{imei}` —— 该键在验签前就会被写,用它判在线必然误判。
|
||||
**不是** Redis 在线键 `device:{imei}` —— 两者回答的是**不同问题**(2026-09-20 逐行核对订正):
|
||||
- `device:{imei}.online` 的写入**都是在验签之后**:`online=1` 只由 api-server 的
|
||||
`report_worker.rs::handle_login` 在 `sign == expected_sign` 分支内(`mark_device_online`)
|
||||
以及 `handle_status_post`(`write_device_state`,该 act 已被登录门控挡住)写入;
|
||||
`handle_auth_str` **不写** `online`。(此前文档写"验签前就会被写"是**错的**。)
|
||||
- 真正不能拿它判下发的原因是**另一条**:它的 TTL 语义是"最近 300 秒收到过该设备任意报文"
|
||||
(协议 §2.3),**断连后仍存活最多 5 分钟** ⇒ 会出现"管理端显示已下发成功、设备实际收不到"。
|
||||
- 另注:`device-server` 的 `server.rs::update_device_route` 会在 `auth_str`/`login` **到达时**
|
||||
写 `device:{imei}` 的 `node_id` 并 `EXPIRE 300`——**只刷 TTL、不写 `online`**,
|
||||
效果是未认证报文也可能把 `online` 的存活期往后顺延一次;这与 §2.3「收到过任意报文即算活跃」
|
||||
一致,故保留,但它进一步说明该键不能当"可下发"判据。
|
||||
⛔ 不要再按"某个 `act` 算不算敏感指令"来增删名单:规则是"登录前只有 `auth_str`/`login`"(常量 `PRE_LOGIN_ALLOWED_ACTS`)。
|
||||
- 协议 `id` 只表示**动作目标**、**不含 IMEI**:**柜级省略** / **一段**=单板(`"01"`)/ **两段**=仓体级(`"01-3"`)
|
||||
- **平台应答必带 `act`(与请求一致)+ `msg_id`(与请求一致)**;判"应答 vs 指令"**只看有没有 `suc`**
|
||||
@ -661,47 +782,57 @@ echo y | plink -ssh root@10.8.0.252 -pw "Hbhyg731024@"
|
||||
|
||||
### 数据库连接
|
||||
|
||||
所有数据库操作通过 ZeroTier 直接连接 `10.8.0.252`,无需 SSH 隧道到服务器。
|
||||
⛔ **不要再用 `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(通过 ZeroTier):**
|
||||
**MySQL(通过 SSH 隧道):**
|
||||
```bash
|
||||
# 先起隧道(另一个终端常驻)
|
||||
pwsh tools/dev-tunnel.ps1
|
||||
|
||||
# 命令行
|
||||
mysql -h 10.8.0.252 -u root -p
|
||||
mysql -h 127.0.0.1 -P 13306 -u root -p
|
||||
# 密码:Hbhyg731024@
|
||||
# 测试环境库:pms_dev
|
||||
# 正式环境库:pms
|
||||
```
|
||||
|
||||
```python
|
||||
# Python(Agent 常用方式)
|
||||
import pymysql
|
||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms') # 正式环境
|
||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms_dev') # 测试环境
|
||||
# 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(通过 ZeroTier):**
|
||||
**Redis(通过 SSH 隧道):**
|
||||
```bash
|
||||
# 命令行
|
||||
redis-cli -h 10.8.0.252 -a "Hbhyg731024@"
|
||||
redis-cli -h 127.0.0.1 -p 16379 -a "Hbhyg731024@"
|
||||
# 测试环境:select 6
|
||||
# 正式环境:select 5
|
||||
```
|
||||
|
||||
```python
|
||||
# Python(Agent 常用方式)
|
||||
import redis
|
||||
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5) # 正式环境
|
||||
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6) # 测试环境
|
||||
# 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`(默认连接测试库 pms_dev):**
|
||||
**数据库工具 `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`(测试库),要操作正式库请用上面的 Python 直连方式。
|
||||
> **`tools/db.py` 默认连测试库 `pms_dev`**(可用 `PMS_MYSQL_DB=pms` 切生产,切换时会打印醒目警告);
|
||||
> 连接失败会打印「连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` …」并 exit 2。
|
||||
|
||||
### Nginx配置
|
||||
|
||||
@ -897,19 +1028,28 @@ scp -r software/web/dist/* root@118.31.119.206:/data/pms/web/ # 正式环境
|
||||
实测正确属主:`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 {}` 块只能有一个** —— MySQL(3306)/Redis(6379) 的 stream 反代写在**主配置
|
||||
`/etc/nginx/nginx.conf`** 里。⛔ **不要在 `/etc/nginx/conf.d/*.conf` 里再写一个 `stream {}`**:
|
||||
nginx 会以 `"stream" directive is duplicate` 直接启动失败(连带整站不可用)。
|
||||
conf.d 下只放 `http` 上下文的 `server {}`。
|
||||
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、116 行、3629 字节)。
|
||||
(**与线上 `/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` / `stream{}` / `worker_shutdown_timeout`)
|
||||
- `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`
|
||||
|
||||
@ -1407,6 +1547,11 @@ const BASE_URL = '/api/pms'
|
||||
`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 特定)
|
||||
|
||||
|
||||
@ -22,12 +22,13 @@ charging-cabinet/
|
||||
├── tasks/ # 任务文件(opencode执行)
|
||||
├── docs/ # 开发文档、协议文档(API.md、柜控板固件对接文档.md …)
|
||||
├── deploy/ # 部署配置与脚本
|
||||
│ ├── nginx/ # Nginx 配置(app / test 两份,与线上一致)
|
||||
│ ├── nginx/ # Nginx 配置镜像(nginx.conf 主配置 + app / test,共三份,与线上逐字节一致)
|
||||
│ ├── *.service # systemd 单元(pms-api / pms-device × prod·test)
|
||||
│ ├── deploy-prod.ps1 # 正式环境一键部署(推荐入口)
|
||||
│ └── deploy-*.sh # ⛔ 已废弃(执行即报错退出)
|
||||
├── tools/ # 开发工具
|
||||
│ ├── db.py # 数据库工具(默认连测试库 pms_dev)
|
||||
│ ├── db.py # 数据库工具(默认连测试库 pms_dev;需先起 SSH 隧道)
|
||||
│ ├── dev-tunnel.ps1 # 开发机 SSH 隧道(127.0.0.1:13306/16379 → 阿里云 RDS/Redis)
|
||||
│ └── db_schema_diff.py # 结构漂移巡检(测试库 vs 生产库 / vs 代码权威 DDL)
|
||||
├── software/
|
||||
│ ├── api-server/ # Rust API服务(产物 pms-api)
|
||||
@ -107,7 +108,9 @@ charging-cabinet/
|
||||
- **死信**:投递失败的指令进 `device:commands:dead`
|
||||
|
||||
> ⚠️ **队列消费一律用 `RPOP` 非阻塞轮询(200ms)+ 空队列 `sleep`,禁止 `BRPOP`** ——
|
||||
> Redis 经 nginx stream 代理 + 多路复用连接时 `BRPOP` 语义不可靠(`BRPOP 0` 会永久卡死且无日志)。
|
||||
> Redis 走 nginx stream 代理 + 多路复用连接时 `BRPOP` 语义不可靠(`BRPOP 0` 会永久卡死且无日志);
|
||||
> 该 stream 转发已于 **2026-09-20 移除**(服务侧现直连阿里云 Redis,开发机走 `tools/dev-tunnel.ps1` 隧道),
|
||||
> 但**轮询约定不变**。
|
||||
> 依据:`workers/report_worker.rs` 的 `REDIS_POLL_INTERVAL_MS` 注释。
|
||||
|
||||
**节点管理:**
|
||||
|
||||
@ -17,11 +17,21 @@
|
||||
# pwsh deploy/deploy-prod.ps1 -SkipBuild # 跳过构建,用现有产物重新部署
|
||||
# pwsh deploy/deploy-prod.ps1 -DryRun # 只打印将要执行的步骤,不做任何改动
|
||||
# pwsh deploy/deploy-prod.ps1 -SkipWeb # 只发后端,不更新前端
|
||||
# pwsh deploy/deploy-prod.ps1 -StrictVerify # 【CI/无人值守请加】"未验证"也当作失败(退出码 1)
|
||||
#
|
||||
# 退出码(遵循仓库统一约定):
|
||||
# 0 = 部署且全部验收通过
|
||||
# 1 = 有验收项失败(含 -StrictVerify 下的"未验证")
|
||||
# 2 = 环境/连接问题(构建产物缺失、SSH/上传失败)
|
||||
# =============================================================================
|
||||
param(
|
||||
[switch]$SkipBuild, # 跳过 cargo zigbuild / npm run build
|
||||
[switch]$SkipWeb, # 跳过前端
|
||||
[switch]$DryRun # 只演练,不执行
|
||||
[switch]$DryRun, # 只演练,不执行
|
||||
# 把"未验证(通常是本地 SSH 隧道没起)"也当成失败:**默认关闭**是因为
|
||||
# "未验证"确实是环境问题而非部署问题,误报会让人习惯性忽略;但 CI/无人值守
|
||||
# 必须打开 —— 否则"探测根本没跑"会被当成绿灯(这正是仓库第 7/8 条假绿的形态)。
|
||||
[switch]$StrictVerify
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@ -136,18 +146,50 @@ journalctl -u pms-api --since '-3 min' --no-pager | grep -c " ERROR " | sed 's/^
|
||||
"@
|
||||
|
||||
# ---- 7. 消费链路探测(无副作用)-------------------------------------------
|
||||
# ⚠️ 本步骤的 probe.py 在**本地**执行,Redis 走 SSH 隧道(tools/dev-tunnel.ps1 的
|
||||
# 127.0.0.1:16379)—— 服务器已不再开放 3306/6379(nginx stream 转发已移除)。
|
||||
# 连不上时**必须显示为"未验证",绝不伪装成通过**(否则部署会带着未验证的链路报成功)。
|
||||
$Unverified = @()
|
||||
$Failures = @()
|
||||
Step '7/8 上报消费链路探测(用不存在的 IMEI,不碰真实设备数据)'
|
||||
if (-not $DryRun) {
|
||||
$probe = @'
|
||||
import redis, json, time
|
||||
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=5, socket_timeout=5)
|
||||
r.lpush('device:reports', json.dumps({"act":"auth_str","dev_id":"899999999999999","msg_id":1}))
|
||||
import os, json, time
|
||||
import redis
|
||||
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
host = os.environ.get("PMS_REDIS_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("PMS_REDIS_PORT", "16379"))
|
||||
try:
|
||||
r = redis.Redis(host=host, port=port, password="Hbhyg731024@", db=5, socket_timeout=5)
|
||||
r.ping()
|
||||
except Exception as e:
|
||||
# 连不上 ≠ 通过:退出码 3 = "本步骤未验证",与 1 = "消费未工作" 明确区分
|
||||
print(" ⚠️ 未验证:连不上 Redis %s:%s(%s)" % (host, port, e))
|
||||
print(" 连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道"
|
||||
"(或设置 PMS_REDIS_HOST/PMS_REDIS_PORT 指向其它可达地址)")
|
||||
raise SystemExit(3)
|
||||
r.lpush("device:reports", json.dumps({"act": "auth_str", "dev_id": "899999999999999", "msg_id": 1}))
|
||||
time.sleep(3)
|
||||
left = r.llen('device:reports')
|
||||
print(f" 队列剩余={left} -> {'✅ 消费正常' if left == 0 else '❌ 消费未工作,请立即回滚'}")
|
||||
left = r.llen("device:reports")
|
||||
if left == 0:
|
||||
print(" 队列剩余=0 -> ✅ 消费正常")
|
||||
raise SystemExit(0)
|
||||
print(" 队列剩余=%d -> ❌ 消费未工作,请立即回滚" % left)
|
||||
raise SystemExit(1)
|
||||
'@
|
||||
$probe | Out-File -Encoding utf8 "$env:TEMP\pms_probe.py"
|
||||
python "$env:TEMP\pms_probe.py"
|
||||
switch ($LASTEXITCODE) {
|
||||
0 { }
|
||||
3 { $Unverified += '上报消费链路探测(Redis 不可达 —— 见上方 ⚠️,通常是本地 SSH 隧道未建立)' }
|
||||
default {
|
||||
Say "❌ 上报消费链路探测失败(exit $LASTEXITCODE)—— 请立即回滚(见上方输出)"
|
||||
# ⚠️ 这里必须**记入失败清单**:此前只打印 ❌ 就继续往下走,脚本最终以 0 退出,
|
||||
# 于是"消费链路已经断了"会被 CI/人当成"部署成功"(仓库第 7/8 条假绿)。
|
||||
$Failures += "上报消费链路探测失败(exit $LASTEXITCODE)—— 消费已停摆,需回滚"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 8. 回滚点保留策略(只保留最近 N 次 backup-YYYYMMDD-HHMMSS)--------------
|
||||
@ -213,9 +255,26 @@ echo " $Dst 占用: `$(du -sh $Dst | cut -f1)"
|
||||
"@
|
||||
|
||||
Write-Host "`n=== 部署完成 ===" -ForegroundColor Green
|
||||
if ($Unverified.Count -gt 0) {
|
||||
Write-Host "⚠️ 有步骤【未验证】(不是通过,请勿当作绿灯):" -ForegroundColor Yellow
|
||||
foreach ($u in $Unverified) { Say " - $u" }
|
||||
Say ' 处理:先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道,再重跑本步确认。'
|
||||
if ($StrictVerify) {
|
||||
Write-Host " ❌ -StrictVerify 已开启:未验证视为失败(退出码 1)" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
if ($Failures.Count -gt 0) {
|
||||
Write-Host "❌ 以下验收项**失败**:" -ForegroundColor Red
|
||||
foreach ($f in $Failures) { Say " - $f" }
|
||||
}
|
||||
Say "正式环境:https://$Domain/pms/"
|
||||
Say "API :$ApiPort TCP :$TcpPort"
|
||||
Say ''
|
||||
Say '回滚方法(如需):'
|
||||
Say " plink -batch -pw <pwd> root@$Host_ `"systemctl stop pms-api pms-device && cp /data/pms/backup-<TS>/* /data/pms/... && systemctl start pms-api pms-device`""
|
||||
Say " 备份目录见 $Dst/backup-<时间戳>/(脚本已把 .env / 前端 / 单元一并备份;只保留最近 $KeepBackups 次)"
|
||||
|
||||
# 退出码:失败优先;-StrictVerify 下"未验证"也算失败。
|
||||
if ($Failures.Count -gt 0) { exit 1 }
|
||||
if ($StrictVerify -and $Unverified.Count -gt 0) { exit 1 }
|
||||
exit 0
|
||||
|
||||
@ -21,8 +21,10 @@ http {
|
||||
# ⛔ 三条硬性约束(任一条违反都会让整站起不来或行为异常,历史已各踩过一次):
|
||||
# 1. limit_req_zone 必须写在 http{} 内 —— conf.d/*.conf 的 location 引用了这些 zone,
|
||||
# 引用未定义 zone 会让 nginx -t 直接失败;
|
||||
# 2. stream{} 全配置**只能有一个**(MySQL 3306 / Redis 6379 反代就在本文件下面那个),
|
||||
# ⛔ 不要在 conf.d/*.conf 里再写一个(报 stream directive is duplicate,整站不可用);
|
||||
# 2. ⛔ stream 转发已移除(2026-09-20):原 MySQL 3306 / Redis 6379 反代(本文件下方那个)
|
||||
# 已整块删除,服务器不再监听库端口;开发机连库走 SSH 隧道(tools/dev-tunnel.ps1)。
|
||||
# 不要在**本文件或 conf.d/*.conf** 里重新加 stream{}(全配置只能有一个,
|
||||
# 写重复会报 stream directive is duplicate,整站不可用)。
|
||||
# 3. worker_shutdown_timeout 30s 不要调短 —— 调短会中断正在传输的响应。
|
||||
limit_req_zone $binary_remote_addr zone=auth_login_zone:10m rate=10r/m;
|
||||
limit_req_zone $binary_remote_addr zone=auth_challenge_zone:10m rate=20r/m;
|
||||
@ -96,21 +98,3 @@ http {
|
||||
# }
|
||||
|
||||
}
|
||||
|
||||
|
||||
stream {
|
||||
server {
|
||||
listen 127.0.0.1:3306;
|
||||
listen 10.8.0.252:3306;
|
||||
proxy_pass rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com:3306;
|
||||
proxy_timeout 10m;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
server {
|
||||
listen 127.0.0.1:6379;
|
||||
listen 10.8.0.252:6379;
|
||||
proxy_pass r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com:6379;
|
||||
proxy_timeout 10m;
|
||||
proxy_connect_timeout 10s;
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,20 +6,44 @@
|
||||
3. 结束该柜未结束的充电订单(`active_flag` 是生成列,由 `end_time` 派生,不能直接写),
|
||||
避免 ⑤ `on` 撞上 `active_flag` 唯一键
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pymysql
|
||||
import redis
|
||||
|
||||
IMEI = '860000000000001'
|
||||
CABINET_ID = 1
|
||||
|
||||
r = redis.Redis(host='10.8.0.252', password='Hbhyg731024@', db=6, decode_responses=True)
|
||||
keys = r.keys(f'device:{IMEI}*') + [f'auth_str:issued:{IMEI}']
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
REDIS_HOST = os.environ.get('PMS_REDIS_HOST', '127.0.0.1')
|
||||
REDIS_PORT = int(os.environ.get('PMS_REDIS_PORT', '16379'))
|
||||
MYSQL_HOST = os.environ.get('PMS_MYSQL_HOST', '127.0.0.1')
|
||||
MYSQL_PORT = int(os.environ.get('PMS_MYSQL_PORT', '13306'))
|
||||
|
||||
# 连不上时给可读提示并 exit 2(隧道没起 ≠ 复位成功,绝不能静默继续)
|
||||
HINT = ('连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道'
|
||||
'(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT / PMS_REDIS_HOST/PMS_REDIS_PORT 指向其它可达地址)')
|
||||
|
||||
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT,
|
||||
password='Hbhyg731024@', db=6, decode_responses=True)
|
||||
try:
|
||||
keys = r.keys(f'device:{IMEI}*') + [f'auth_str:issued:{IMEI}']
|
||||
except redis.RedisError as e:
|
||||
print(f'✗ Redis 连接失败 {REDIS_HOST}:{REDIS_PORT}:{e}', file=sys.stderr)
|
||||
print(HINT, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print('Redis 待删除 key 数:', len(keys))
|
||||
if keys:
|
||||
print('Redis deleted:', r.delete(*keys))
|
||||
|
||||
c = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@',
|
||||
database='pms_dev', autocommit=True)
|
||||
try:
|
||||
c = pymysql.connect(host=MYSQL_HOST, port=MYSQL_PORT, user='root', password='Hbhyg731024@',
|
||||
database='pms_dev', autocommit=True)
|
||||
except pymysql.MySQLError as e:
|
||||
print(f'✗ MySQL 连接失败 {MYSQL_HOST}:{MYSQL_PORT}:{e}', file=sys.stderr)
|
||||
print(HINT, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
cur = c.cursor()
|
||||
cur.execute("UPDATE cabinets SET auth_str_issued = 0 WHERE imei = %s", (IMEI,))
|
||||
print('auth_str_issued 置 0 行数:', cur.rowcount)
|
||||
|
||||
@ -66,8 +66,8 @@ ENVS = {
|
||||
"admin_phone": "13912391234",
|
||||
},
|
||||
}
|
||||
DEFAULT_HOST = "10.8.0.252"
|
||||
DEFAULT_PORT = 3306
|
||||
DEFAULT_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1")
|
||||
DEFAULT_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306"))
|
||||
DEFAULT_USER = "root"
|
||||
DEFAULT_PASSWORD = "Hbhyg731024@"
|
||||
|
||||
@ -166,7 +166,9 @@ def main():
|
||||
password=args.password, database=env["db"],
|
||||
charset="utf8mb4", autocommit=True)
|
||||
except pymysql.MySQLError as e:
|
||||
die("数据库连接失败:%s" % e)
|
||||
die("数据库连接失败:%s\n"
|
||||
"连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道"
|
||||
"(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)" % e)
|
||||
cur = conn.cursor()
|
||||
phone = env.get("temp_phone")
|
||||
|
||||
|
||||
@ -10,11 +10,54 @@
|
||||
| `probe_prod.py` | A/D 项(生产):用服务端 `JWT_SECRET` 临时签一个 token,只打"应被拒绝"的路径 + 反馈图测试文件(末尾自删),并打印 CORS 白名单内外行为 | 服务器(打 `127.0.0.1:9000` + 走域名) |
|
||||
| `probe_export.py` | C 项:Excel 导出(写 `./exports`)在降权后是否可用(测试环境) | 服务器(打 `127.0.0.1:3000`) |
|
||||
| `smoke_prod.py` | 生产冒烟:SPA 资源、challenge、登录参数校验,并清理测试产生的 Redis 计数键 | 服务器 |
|
||||
| `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(直连 `10.8.0.252`) |
|
||||
| `make_test_user.py` | 在 `pms_dev` 建/重置实测账号 `13900000009 / SecTest#2026`(role=0,无组织) | 本地(经 SSH 隧道 `127.0.0.1:13306`,先跑 `pwsh tools/dev-tunnel.ps1`;⛔ 不再直连 `10.8.0.252`) |
|
||||
| `nginx-hardening.sh` | D 项:把两份 conf 落到 `/etc/nginx/conf.d/`,在 `nginx.conf` 的 `http{}` 内插入 `limit_req_zone`,`nginx -t` 通过才 reload,失败回滚 | 服务器(先 `pscp` conf 到 `/tmp`) |
|
||||
| `switch-user-test.sh` | C 项:测试环境两单元 `User=root → User=pms`,失败立即回滚 | 服务器 |
|
||||
| `deploy-prod-sec.sh` | 生产:二进制原子替换 + 两单元降权,失败回滚(单元 + 二进制) | 服务器 |
|
||||
|
||||
## 退出码约定(2026-09-20 定稿,全仓统一)
|
||||
|
||||
| 退出码 | 含义 |
|
||||
|--------|------|
|
||||
| `0` | 全部判据通过 |
|
||||
| `1` | **有判据失败**(失败项额外打到 stderr) |
|
||||
| `2` | 环境/连接问题(读不到 `.env`、连不上 Redis/后端 —— 各脚本用 `raise SystemExit(...)` 报出) |
|
||||
|
||||
- 探针的判据与退出码统一由**同目录的 `verdict.py`** 提供(`Verdict.check(label, passed, detail)` + `exit_code()`)。
|
||||
- ⛔ **"打印了 ❌ 却 exit 0"是假绿**:`probe_upload` / `probe_prod` / `probe_bruteforce` / `smoke_prod`
|
||||
早期结尾写死 `return 0`,**无论安全控制是否生效都 exit 0**,与 `tsc --noEmit` 空跑同类。
|
||||
已改为"失败 → exit 1",并按仓库铁律**实测过失败路径**:
|
||||
把 `probe_upload.py` 的 A1 判据翻成 `st == 200` 后在服务器上跑 → **exit 1**;
|
||||
恢复后 → **6/6 判据通过、exit 0**(`verdict.py` 本身另有 0/1/空判据 三种自测)。
|
||||
- ⚠️ **拿不准阈值的现象不写成判据**(如 challenge 限流"第几次触发 429"、并发耗时)——只作观察输出,
|
||||
否则会误报;误报与假绿同样有害。
|
||||
- ✅ **C 项(降权后 Excel 导出落盘)结论:可用 —— 由落盘物证直接判定(2026-09-20)**
|
||||
- 测试环境:`/data/pms_dev/api-server/exports/充电记录__-.xlsx`,**5559 B,属主 `pms:pms`**,
|
||||
时间 04:29(与 `test/schema_alignment_e2e.py` 的成功导出同一时刻);
|
||||
- 生产环境:同类产物 5457 B、属主同为 `pms:pms`;
|
||||
- 两环境 `exports/` 与 `uploads/` 目录均为 `drwxr-xr-x pms pms`,两个 api-server 单元均为
|
||||
`User=pms` / `Group=pms`(`systemctl show -p User -p Group` 实测)。
|
||||
- ⇒ 降权成 `pms` 的进程**确实能往 `exports/` 落盘**(这正是 C 项要验证的事)。
|
||||
- ⚠️ 上述结论**不依赖 `probe_export.py`**:该脚本因测试库唯一账号没有任何 `user_roles`(无
|
||||
`charge_record:export`)仍返回 `exit 2`「未验证」(见下条)。想让它也给出判决,二选一补齐夹具。
|
||||
- ⚠️ **`probe_export.py` 当前是"未验证"(exit 2),不是"失败"** —— 实测测试库 `pms_dev`
|
||||
**只有 1 个账号(`13900000009`)且 `user_roles` 表为空**,即它**一个权限码都没有**:
|
||||
创建导出任务恒 `403 缺少权限: charge_record:export`。该探针要回答的是
|
||||
"**降权成 `pms` 后还能不能往 `exports/` 写文件**",而没权限根本走不到那一步 ——
|
||||
早期把这种情形报成 `exit 1` 属**误报**(把夹具缺陷说成产品缺陷)。
|
||||
现已改为 `exit 2`(环境/夹具问题,"未验证")。**补齐夹具后必须重跑**,二选一:
|
||||
① 给该账号配含 `charge_record:export` 的角色;
|
||||
② 用 `PMS_PROBE_PHONE` / `PMS_PROBE_PASSWORD` 环境变量给一个超管账号。
|
||||
- 🐞 **`probe_export.py` 曾把任务状态语义搞反(已修,值得记住)**:
|
||||
`download_tasks.status` 的**权威语义是 `0 排队 / 1 处理中 / 2 完成 / 3 失败`**
|
||||
(`db/migrate.rs:225` 的列注释;写入侧 `routes/charge_records.rs::mark_task_completed` 写的就是 `2`)。
|
||||
该脚本此前**等 `status == 1`(处理中)当成功、把 `status == 2`(完成)当"导出任务失败"** ——
|
||||
即**导出成功也会被判失败**(假红)。已按权威语义订正,并与另外两处正确口径对齐:
|
||||
`web/src/pages/charge-records/DownloadCenter.tsx::statusText`、
|
||||
`test/schema_alignment_e2e.py`(等 `status in (2,3)` 再断言 `== 2`)。
|
||||
> 教训:**状态码这种"看着像布尔"的字段,一定要回权威 DDL 的列注释核对,别照字面猜**;
|
||||
> 三处读同一个字段就更要三处对齐(本次就是两处对一个错)。
|
||||
|
||||
## 前置说明
|
||||
|
||||
- 脚本内有 `ENV_FILE` 常量指向线上 `.env`(读 `REDIS_URL` / `JWT_SECRET`),**不要把 .env 内容复制到仓库**。
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""在 pms_dev(测试库)创建/重置一个安全测试账号,用于实测登录锁定与上传。
|
||||
账号:13900000009 / SecTest#2026(role=0 操作员,status=1 启用,无组织)
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pymysql
|
||||
@ -9,8 +10,11 @@ from argon2 import PasswordHasher
|
||||
PHONE = "13900000009"
|
||||
PASSWORD = "SecTest#2026"
|
||||
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
conn = pymysql.connect(
|
||||
host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev", autocommit=True
|
||||
host=os.environ.get("PMS_MYSQL_HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("PMS_MYSQL_PORT", "13306")),
|
||||
user="root", password="Hbhyg731024@", database="pms_dev", autocommit=True,
|
||||
)
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
|
||||
@ -15,6 +15,8 @@ import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题)
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
ENV_FILE = "/data/pms_dev/api-server/.env"
|
||||
PHONE = "13900000009"
|
||||
@ -101,6 +103,8 @@ def counters(redis, ip):
|
||||
|
||||
|
||||
def main():
|
||||
# 判据容器:失败 → exit 1(此前写死 return 0,属假绿)
|
||||
v = Verdict("probe_bruteforce(登录锁定 / challenge 限流)")
|
||||
redis = Redis(read_env("REDIS_URL"))
|
||||
for k in (FAIL_KEY, "auth:fail:ip:10.9.9.4", "auth:fail:ip:10.9.9.5", "auth:fail:ip:10.9.9.6"):
|
||||
redis.cmd("DEL", k)
|
||||
@ -109,23 +113,36 @@ def main():
|
||||
for i in (1, 2):
|
||||
st, body = login_attempt(redis, "10.9.9.4", BAD)
|
||||
print(f" 错密码#{i} status={st} body={body.decode()[:120]}")
|
||||
print(f" 计数(2 次失败后): {counters(redis, '10.9.9.4')}")
|
||||
c_after_fail = counters(redis, "10.9.9.4")
|
||||
print(f" 计数(2 次失败后): {c_after_fail}")
|
||||
v.check("B1a 2 次错密码应记入失败计数", c_after_fail["phone"] is not None,
|
||||
f'phone={c_after_fail["phone"]}')
|
||||
st, body = login_attempt(redis, "10.9.9.4", GOOD)
|
||||
print(f" 正确密码 status={st} body={body.decode()[:120]}")
|
||||
print(f" 计数(登录成功后应为 None): {counters(redis, '10.9.9.4')}")
|
||||
v.check("B1b 正确密码应登录成功", st == 200, f"status={st}")
|
||||
c_after_ok = counters(redis, "10.9.9.4")
|
||||
print(f" 计数(登录成功后应为 None): {c_after_ok}")
|
||||
v.check("B1c 登录成功后失败计数应被清零", c_after_ok["phone"] is None,
|
||||
f'phone={c_after_ok["phone"]}')
|
||||
|
||||
print("\n===== B2 连续失败锁定(阈值 5 次 / 15 分钟)=====")
|
||||
last_st = None
|
||||
for i in range(1, 7):
|
||||
st, body = login_attempt(redis, "10.9.9.5", BAD)
|
||||
last_st = st
|
||||
print(f" 错密码#{i} status={st} body={body.decode()[:120]} counters={counters(redis, '10.9.9.5')}")
|
||||
v.check("B2a 第 6 次错密码应被拒(非 200)", last_st != 200, f"status={last_st}")
|
||||
print(" 锁定后再用【正确密码】尝试(应仍被拒,且文案与密码错误完全一致,不泄露锁定状态):")
|
||||
st, body = login_attempt(redis, "10.9.9.7", GOOD)
|
||||
print(f" 正确密码 status={st} body={body.decode()[:120]}")
|
||||
v.check("B2b 锁定后用正确密码也应被拒(文案不泄露锁定状态)", st != 200, f"status={st}")
|
||||
|
||||
print("\n===== B3 challenge 限流(同一 IP 12 次)=====")
|
||||
for i in range(1, 13):
|
||||
st, body = http("GET", "/api/auth/challenge", headers={"X-Real-IP": "10.9.9.6"})
|
||||
print(f" challenge#{i} status={st} body={body.decode()[:80]}")
|
||||
# ⚠️ 不加硬判据:触发点取决于 nginx `auth_challenge_zone` 的 rate(AGENTS.md 记 20r/m,
|
||||
# 而本脚本注释写"第 11 次起 429")—— 口径不一致时写死判据会误报,故保持观察输出。
|
||||
|
||||
print("\n===== B4 并发闸门(Semaphore=2,不同 IP 并发请求 challenge)=====")
|
||||
t0 = time.time()
|
||||
@ -146,12 +163,13 @@ def main():
|
||||
for r in results:
|
||||
codes[r] = codes.get(r, 0) + 1
|
||||
print(f" {n} 并发(不同 IP)耗时 {wall:.2f}s,状态码分布={codes}")
|
||||
# ⚠️ 同上:并发耗时/状态码只作观察,不设判据。
|
||||
|
||||
print("\n===== 清理测试计数键 =====")
|
||||
for k in (FAIL_KEY, "auth:fail:ip:10.9.9.4", "auth:fail:ip:10.9.9.5", "auth:fail:ip:10.9.9.7"):
|
||||
redis.cmd("DEL", k)
|
||||
print(f" 清理后 phone 计数 = {redis.cmd('GET', FAIL_KEY)}")
|
||||
return 0
|
||||
return v.exit_code() # 0=判据全过,1=有判据失败
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -2,8 +2,22 @@
|
||||
"""C 项补充实测:降权后(uid=pms)Excel 导出(写 ./exports)仍可用
|
||||
|
||||
流程:登录 → 创建导出任务 → 轮询任务状态 → 下载文件 → 打印文件落盘信息
|
||||
|
||||
退出码(仓库统一约定 0/1/2):
|
||||
- `0` = 导出链路可用(任务成功 + 下载到 xlsx)
|
||||
- `1` = **判据失败**:导出链路真的坏了(任务 status=2 / 下载非 xlsx)
|
||||
- `2` = **环境或夹具问题,本项"未验证"** —— 登录失败、或账号**缺少 `charge_record:export`
|
||||
权限码**(403)。⚠️ 这一档必须与 1 分开:本探针要回答的是"**降权后能不能往 exports 写文件**",
|
||||
账号没权限根本走不到那一步,报 1 就是**把夹具缺陷说成产品缺陷**(误报与假绿同样有害)。
|
||||
实测(2026-09-20)测试库 `pms_dev` **只有 1 个账号且没有任何 user_roles**,
|
||||
因此这一项在补齐夹具前**恒为"未验证"**,不能算"导出不可用"。
|
||||
|
||||
夹具要求(两条路任选):
|
||||
1. 给测试账号配含 `charge_record:export` 的角色;或
|
||||
2. 用环境变量给一个超管账号:`PMS_PROBE_PHONE` / `PMS_PROBE_PASSWORD`。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
@ -13,8 +27,9 @@ from urllib.parse import unquote, urlparse
|
||||
|
||||
BASE = "http://127.0.0.1:3000"
|
||||
ENV_FILE = "/data/pms_dev/api-server/.env"
|
||||
PHONE = "13900000009"
|
||||
PASSWORD = "SecTest#2026"
|
||||
# 允许用环境变量换成"确有 charge_record:export 权限"的账号(默认沿用安全测试账号)
|
||||
PHONE = os.environ.get("PMS_PROBE_PHONE", "13900000009")
|
||||
PASSWORD = os.environ.get("PMS_PROBE_PASSWORD", "SecTest#2026")
|
||||
|
||||
|
||||
class Redis:
|
||||
@ -77,32 +92,60 @@ def main():
|
||||
)
|
||||
if st != 200:
|
||||
print(f"[login] {st} {body.decode()[:200]}")
|
||||
return 1
|
||||
print(" ⚠️ 未验证(exit 2):登录失败属环境/夹具问题,不是导出链路缺陷")
|
||||
return 2
|
||||
token = json.loads(body)["data"]["token"]
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
st, body = http("GET", "/api/charge-records/export?start_time=2020-01-01%2000:00:00&end_time=2030-01-01%2000:00:00", headers=auth)
|
||||
print(f"[export] create -> {st} {body.decode()[:200]}")
|
||||
if st == 403:
|
||||
# 关键区分:403 说明**账号没有权限码**,根本没走到"往 exports 写文件"那一步。
|
||||
# 把它算成失败(退出码 1)会让人以为"降权后导出不可用",是误报。
|
||||
print(" ⚠️ 未验证(exit 2):账号缺少 charge_record:export 权限,本项无法判定")
|
||||
print(" 夹具要求见本文件顶部 docstring(配权限 或 设 PMS_PROBE_PHONE/PASSWORD)")
|
||||
return 2
|
||||
if st != 200:
|
||||
print(f" ❌ exit 1:创建导出任务失败(HTTP {st}),非权限问题")
|
||||
return 1
|
||||
task_id = json.loads(body)["data"]["task_id"]
|
||||
|
||||
# ⚠️ `download_tasks.status` 的**权威语义**(`db/migrate.rs:225` 列注释 +
|
||||
# `routes/charge_records.rs:435` 的 `mark_task_completed` 写的就是 2):
|
||||
# 0=排队 1=处理中 2=**完成** 3=失败
|
||||
# 本脚本此前**把语义搞反了**(等 `status == 1`、把 `status == 2` 判成"导出任务失败"),
|
||||
# 于是"导出成功"会被报成失败(假红),与 console 端 `DownloadCenter.tsx::statusText`
|
||||
# 和 `test/schema_alignment_e2e.py` 的口径都不一致。已按权威语义订正。
|
||||
STATUS_DONE, STATUS_FAILED = 2, 3
|
||||
|
||||
file_path = None
|
||||
for _ in range(15):
|
||||
for _ in range(20):
|
||||
time.sleep(1)
|
||||
st, body = http("GET", f"/api/downloads/{task_id}", headers=auth)
|
||||
info = json.loads(body)["data"]
|
||||
print(f"[export] task={task_id} status={info.get('status')} progress={info.get('progress')} file_path={info.get('file_path')}")
|
||||
if info.get("status") == 1:
|
||||
cur = info.get("status")
|
||||
print(f"[export] task={task_id} status={cur} progress={info.get('progress')} file_path={info.get('file_path')}")
|
||||
if cur == STATUS_DONE:
|
||||
file_path = info.get("file_path")
|
||||
break
|
||||
if info.get("status") == 2:
|
||||
print("导出任务失败")
|
||||
if cur == STATUS_FAILED:
|
||||
print(f" ❌ exit 1:导出任务失败(status=3)err={info.get('error_message')!r}")
|
||||
return 1
|
||||
else:
|
||||
print(" ❌ exit 1:120 秒内任务未进入终态(既没完成也没失败)—— 导出链路可能卡住")
|
||||
return 1
|
||||
|
||||
if not file_path:
|
||||
print(" ❌ exit 1:任务标记完成但没有 file_path —— 落盘环节有问题")
|
||||
return 1
|
||||
|
||||
st, blob = http("GET", f"/api/downloads/{task_id}/file", headers=auth)
|
||||
print(f"[download] GET /api/downloads/{task_id}/file -> {st} bytes={len(blob)} head={blob[:4]!r}")
|
||||
return 0 if (st == 200 and blob[:2] == b"PK") else 1
|
||||
if st == 200 and blob[:2] == b"PK":
|
||||
print(" ✅ exit 0:导出链路可用(降权成 pms 后仍能往 exports/ 落盘并下载)")
|
||||
return 0
|
||||
print(" ❌ exit 1:下载失败或不是 xlsx(xlsx 是 zip,魔数应为 PK)")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -16,6 +16,8 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题)
|
||||
|
||||
BASE = "http://127.0.0.1:9000" # 直连后端(应用层行为)
|
||||
DOMAIN = "https://app.anzhizhichong.com" # 走 nginx(看响应头/CORS)
|
||||
ENV_FILE = "/data/pms/api-server/.env"
|
||||
@ -75,6 +77,8 @@ def multipart(field, filename, content, ctype):
|
||||
|
||||
|
||||
def main():
|
||||
# 判据容器:失败 → exit 1(此前写死 return 0,属假绿)
|
||||
v = Verdict("probe_prod(生产上传拒绝路径 / CORS)")
|
||||
token = mint_token(env("JWT_SECRET"), UID, PHONE)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
@ -93,6 +97,7 @@ def main():
|
||||
"POST", BASE + "/api/auth/avatar/upload", data, {**auth, "Content-Type": ct}
|
||||
)
|
||||
print(f" avatar filename={fn} -> {st} {body.decode()[:140]}")
|
||||
v.check(f"生产头像:{fn} 应被拒", st != 200, f"status={st}")
|
||||
|
||||
print("\n===== 生产 反馈图上传(拒绝路径 + 一个真实 PNG 测试文件)=====")
|
||||
url = None
|
||||
@ -104,6 +109,8 @@ def main():
|
||||
data, ct = multipart("file", fn, content, ctype)
|
||||
st, _, body = http("POST", BASE + "/api/feedback/upload", data, {**auth, "Content-Type": ct})
|
||||
print(f" feedback filename={fn}(应{expect})-> {st} {body.decode()[:140]}")
|
||||
v.check(f"生产反馈图:{fn} 应{expect}", (st != 200) if expect == "拒" else (st == 200),
|
||||
f"status={st}")
|
||||
if st == 200:
|
||||
url = json.loads(body)["data"]["url"]
|
||||
|
||||
@ -119,6 +126,8 @@ def main():
|
||||
print(f"[cleanup] 已删除我自己的测试文件 {path}")
|
||||
|
||||
print("\n===== 生产 CORS 白名单 =====")
|
||||
# ⚠️ CORS 只作观察:是否回 `access-control-allow-origin` 还取决于浏览器语义与后端实现口径,
|
||||
# 写成硬判据容易误报 ⇒ 保持观察输出(误报与假绿同样有害)。
|
||||
for origin in (
|
||||
"https://app.anzhizhichong.com",
|
||||
"https://test.anzhizhichong.com",
|
||||
@ -131,7 +140,7 @@ def main():
|
||||
f" Origin={origin:<32} -> {st} allow-origin="
|
||||
f"{hdrs.get('access-control-allow-origin', '(无)')}"
|
||||
)
|
||||
return 0
|
||||
return v.exit_code() # 0=判据全过,1=有判据失败
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -13,6 +13,8 @@ import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
|
||||
from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题)
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import unquote, urlparse
|
||||
@ -124,20 +126,28 @@ def try_upload(token, path, field, filename, content, ctype, label):
|
||||
|
||||
|
||||
def main():
|
||||
# 判据容器:失败 → exit 1(见 verdict.py;此前本脚本写死 return 0,属假绿)
|
||||
v = Verdict("probe_upload(测试环境 uploads 安全)")
|
||||
redis = Redis(read_env("REDIS_URL"))
|
||||
token = login(redis)
|
||||
auth = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
print("\n===== 头像上传 /api/auth/avatar/upload =====")
|
||||
try_upload(token, "/api/auth/avatar/upload", "file", "xss.svg", SVG, "image/svg+xml", "A1 svg")
|
||||
try_upload(token, "/api/auth/avatar/upload", "file", "xss.png", SVG, "image/png", "A2 svg-as-png")
|
||||
st, _ = try_upload(token, "/api/auth/avatar/upload", "file", "xss.svg", SVG, "image/svg+xml", "A1 svg")
|
||||
v.check("A1 头像:svg(image/svg+xml)应被拒", st != 200, f"status={st}")
|
||||
st, _ = try_upload(token, "/api/auth/avatar/upload", "file", "xss.png", SVG, "image/png", "A2 svg-as-png")
|
||||
v.check("A2 头像:改名 .png 的 SVG(魔数兜底)应被拒", st != 200, f"status={st}")
|
||||
st, resp = try_upload(token, "/api/auth/avatar/upload", "file", "ok.png", PNG, "image/png", "A3 real-png")
|
||||
v.check("A3 头像:真 PNG 应通过", st == 200, f"status={st}")
|
||||
ok_url = json.loads(resp)["data"]["avatar"] if st == 200 else None
|
||||
|
||||
print("\n===== 反馈图上传 /api/feedback/upload =====")
|
||||
try_upload(token, "/api/feedback/upload", "file", "xss.svg", SVG, "image/svg+xml", "A4 svg")
|
||||
try_upload(token, "/api/feedback/upload", "file", "xss2.png", SVG, "image/png", "A5 svg-as-png")
|
||||
st, _ = try_upload(token, "/api/feedback/upload", "file", "xss.svg", SVG, "image/svg+xml", "A4 svg")
|
||||
v.check("A4 反馈图:svg 应被拒", st != 200, f"status={st}")
|
||||
st, _ = try_upload(token, "/api/feedback/upload", "file", "xss2.png", SVG, "image/png", "A5 svg-as-png")
|
||||
v.check("A5 反馈图:改名 .png 的 SVG 应被拒", st != 200, f"status={st}")
|
||||
fb_st, fb_resp = try_upload(token, "/api/feedback/upload", "file", "ok.png", PNG, "image/png", "A6 real-png")
|
||||
v.check("A6 反馈图:真 PNG 应通过", fb_st == 200, f"status={fb_st}")
|
||||
|
||||
print("\n===== 上传后静态访问(直连 :3000,验证 nosniff 由 nginx 加,这里看后端 Content-Type)=====")
|
||||
for url in filter(None, [ok_url, json.loads(fb_resp)["data"]["url"] if fb_st == 200 else None]):
|
||||
@ -148,7 +158,7 @@ def main():
|
||||
for d in ("/data/pms_dev/api-server/uploads/avatars", "/data/pms_dev/api-server/uploads/feedback"):
|
||||
if os.path.isdir(d):
|
||||
print(f"{d}: {sorted(os.listdir(d))}")
|
||||
return 0
|
||||
return v.exit_code() # 0=判据全过,1=有判据失败
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@ -6,6 +6,8 @@ import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from verdict import Verdict # 同目录:判据 + 统一退出码(0 通过 / 1 有失败 / 2 环境问题)
|
||||
|
||||
DOMAIN = "https://app.anzhizhichong.com"
|
||||
ENV_FILE = "/data/pms/api-server/.env"
|
||||
FAKE_PHONE = "00000000000"
|
||||
@ -57,8 +59,11 @@ def http(method, path, data=None, headers=None):
|
||||
|
||||
|
||||
def main():
|
||||
# 判据容器:失败 → exit 1(此前写死 return 0,属假绿)
|
||||
v = Verdict("smoke_prod(生产冒烟:SPA / challenge / 登录参数校验)")
|
||||
st, _, body = http("GET", "/pms/")
|
||||
print(f"[SPA] GET /pms/ -> {st} bytes={len(body)}")
|
||||
v.check("生产 SPA /pms/ 应 200", st == 200, f"status={st}")
|
||||
asset = None
|
||||
for token in body.decode(errors="ignore").split('"'):
|
||||
if token.startswith("/pms/assets/") and token.endswith(".js"):
|
||||
@ -67,9 +72,13 @@ def main():
|
||||
if asset:
|
||||
st, _, blob = http("GET", asset)
|
||||
print(f"[SPA] GET {asset} -> {st} bytes={len(blob)}")
|
||||
v.check(f"生产 SPA 资源 {asset} 应 200", st == 200, f"status={st}")
|
||||
else:
|
||||
v.check("生产 SPA 页面应引用 /pms/assets/*.js", False, "未在 HTML 中找到资源引用")
|
||||
|
||||
st, _, body = http("GET", "/api/pms/auth/challenge")
|
||||
print(f"[login] GET /api/pms/auth/challenge -> {st} (JSON 长度 {len(body)})")
|
||||
v.check("challenge 接口应 200", st == 200, f"status={st}")
|
||||
|
||||
st, _, body = http(
|
||||
"POST",
|
||||
@ -78,12 +87,13 @@ def main():
|
||||
{"Content-Type": "application/json"},
|
||||
)
|
||||
print(f"[login] POST /api/pms/auth/login(无 challenge)-> {st} {body.decode()[:120]}")
|
||||
v.check("无 challenge 的登录必须被拒(非 200)", st != 200, f"status={st}")
|
||||
|
||||
redis = Redis(env("REDIS_URL"))
|
||||
for key in (f"auth:fail:phone:{FAKE_PHONE}", "auth:fail:ip:unknown"):
|
||||
redis.cmd("DEL", key)
|
||||
print("[cleanup] 清理测试产生的 Redis 计数键完成")
|
||||
return 0
|
||||
return v.exit_code() # 0=判据全过,1=有判据失败
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
69
test/security-hardening/verdict.py
Normal file
69
test/security-hardening/verdict.py
Normal file
@ -0,0 +1,69 @@
|
||||
"""安全探针统一的「判据 + 退出码」小工具(供 test/security-hardening/*.py 使用)。
|
||||
|
||||
为什么需要它
|
||||
------------
|
||||
这些探针早期只 `print` 观察结果、结尾写死 `return 0` ⇒ **无论安全控制是否生效都 exit 0**:
|
||||
人眼看到的是 ❌/异常现象,自动化(CI、部署前置检查、agent 验收)看到的却是绿灯。
|
||||
这与 `tsc --noEmit` 空跑、`tcp_e2e_smoke.py` 永远 exit 0 属**同一类假绿**。
|
||||
|
||||
统一口径(与 AGENTS.md「验收流程」一致)
|
||||
--------------------------------------
|
||||
0 = 全部判据通过
|
||||
1 = 有判据失败(失败项额外打到 stderr,便于只看 stderr 定位)
|
||||
2 = 环境/连接问题(调用方自行 `raise SystemExit(2)`,例如读不到 .env、连不上 Redis/后端)
|
||||
|
||||
用法
|
||||
----
|
||||
import sys
|
||||
from verdict import Verdict
|
||||
|
||||
v = Verdict("probe_upload(测试环境 uploads 安全)")
|
||||
v.check("A1 头像上传 svg 应被拒", st != 200, f"status={st}")
|
||||
...
|
||||
sys.exit(v.exit_code())
|
||||
|
||||
约定
|
||||
----
|
||||
- 判据只写**明确、可复现**的期望("应被拒" = status != 200;"应通过" = status == 200);
|
||||
拿不准阈值/口径的现象(如限流具体第几次触发)**不要**写成判据,保持为观察输出,
|
||||
否则会制造误报 —— 误报与假绿同样有害。
|
||||
- 判据数可能为 0(纯取证脚本):此时 exit_code() 返回 0,但会打印"未设置判据"提醒。
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
class Verdict:
|
||||
def __init__(self, title):
|
||||
self.title = title
|
||||
self.checks = [] # [(label, passed, detail)]
|
||||
|
||||
def check(self, label, passed, detail=""):
|
||||
"""记录一条判据;返回 bool 便于就地分支"""
|
||||
passed = bool(passed)
|
||||
self.checks.append((label, passed, detail))
|
||||
return passed
|
||||
|
||||
@property
|
||||
def failed(self):
|
||||
return [(l, d) for l, p, d in self.checks if not p]
|
||||
|
||||
def exit_code(self):
|
||||
"""打印结论并返回退出码(0=全通过;1=有失败)"""
|
||||
n_pass = len(self.checks) - len(self.failed)
|
||||
print(f"\n===== 判据结论:{self.title} =====")
|
||||
for label, passed, detail in self.checks:
|
||||
suffix = f" ({detail})" if detail else ""
|
||||
print(f" {'✅' if passed else '❌'} {label}{suffix}")
|
||||
print(f" 合计 {n_pass}/{len(self.checks)} 判据通过")
|
||||
if not self.checks:
|
||||
print(" ⚠️ 本脚本未设置任何判据(纯取证脚本)—— 不要拿它的退出码当验收判据")
|
||||
return 0
|
||||
if self.failed:
|
||||
print("\n未通过判据(stderr):", file=sys.stderr)
|
||||
for label, detail in self.failed:
|
||||
suffix = f" ({detail})" if detail else ""
|
||||
print(f" ❌ {label}{suffix}", file=sys.stderr)
|
||||
print(f"合计 {n_pass}/{len(self.checks)} 判据通过,{len(self.failed)} 项失败", file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
每一步都打印平台的应答,便于人工核对协议实现。
|
||||
"""
|
||||
import sys
|
||||
import hashlib
|
||||
import json
|
||||
import socket
|
||||
@ -154,3 +155,12 @@ print("\n========== 联调结果 ==========")
|
||||
for name, passed in ok:
|
||||
print(f" {'✅' if passed else '❌'} {name}")
|
||||
print(f"\n 合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过")
|
||||
# ⛔ 退出码必须反映结果:0=全部通过,1=有失败(此前本脚本永远 exit 0,属假绿);
|
||||
# 失败项额外打到 stderr,便于 CI/人只看 stderr 就能定位(口径见 AGENTS.md「验收流程」)。
|
||||
failed = [n for n, p in ok if not p]
|
||||
if failed:
|
||||
print("未通过项(stderr):", file=sys.stderr)
|
||||
for n in failed:
|
||||
print(f" ❌ {n}", file=sys.stderr)
|
||||
print(f"合计 {sum(1 for _, p in ok if p)}/{len(ok)} 通过,{len(failed)} 项失败", file=sys.stderr)
|
||||
sys.exit(0 if not failed else 1)
|
||||
|
||||
@ -1,16 +1,32 @@
|
||||
"""测量端到端真实延迟:发一条 auth_str,看应答多久到达(最长等 75 秒)。"""
|
||||
"""测量端到端真实延迟:发一条 auth_str,看应答多久到达(最长等 75 秒)。
|
||||
|
||||
退出码(全仓统一口径):0 = 三步都有应答;1 = 有步骤无应答/断言失败;
|
||||
2 = 连接或环境问题(DB 连不上、TCP 建连失败)。
|
||||
⛔ 以前它只打印 ✅/❌ 却永远 exit 0 —— 无应答也算"通过",属假绿。
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import pymysql
|
||||
|
||||
# 3001 是 device-server 的 TCP 业务端口(设备经公网/ZeroTier 直连),与数据库通路无关,保持不变
|
||||
HOST, PORT = "10.8.0.252", 3001
|
||||
IMEI = "860000000000001"
|
||||
|
||||
# 复位夹具,保证能重新申领
|
||||
conn = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@",
|
||||
database="pms_dev", connect_timeout=8, autocommit=True)
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
try:
|
||||
conn = pymysql.connect(host=os.environ.get("PMS_MYSQL_HOST", "127.0.0.1"),
|
||||
port=int(os.environ.get("PMS_MYSQL_PORT", "13306")),
|
||||
user="root", password="Hbhyg731024@",
|
||||
database="pms_dev", connect_timeout=8, autocommit=True)
|
||||
except pymysql.MySQLError as e:
|
||||
print(f"✗ MySQL 连接失败:{e}", file=sys.stderr)
|
||||
print("连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE cabinets SET auth_str=NULL, auth_str_issued=0 WHERE imei=%s", (IMEI,))
|
||||
cur.execute("DELETE FROM charge_records WHERE cabinet_id=1")
|
||||
@ -21,9 +37,15 @@ cur.execute("UPDATE compartments SET status=0")
|
||||
conn.close()
|
||||
print(" 夹具已复位")
|
||||
|
||||
s = socket.create_connection((HOST, PORT), timeout=5)
|
||||
try:
|
||||
s = socket.create_connection((HOST, PORT), timeout=5)
|
||||
except OSError as e:
|
||||
print(f"✗ TCP 建连失败 {HOST}:{PORT}:{e}", file=sys.stderr)
|
||||
print("(该端口是 device-server 业务端口;测试环境为 :3001)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
s.settimeout(1.0)
|
||||
buf = b""
|
||||
fails = [] # 收集失败项,决定退出码(非 0 = 失败)
|
||||
|
||||
|
||||
def send(obj):
|
||||
@ -55,9 +77,12 @@ dt, resp = wait_reply(t0)
|
||||
if resp:
|
||||
print(f" ✅ 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False)}")
|
||||
auth_str = resp.get("auth_str")
|
||||
if not auth_str:
|
||||
fails.append("① auth_str 有应答但未带 auth_str")
|
||||
else:
|
||||
print(f" ❌ {dt if dt else '>75'}s 内无应答")
|
||||
auth_str = None
|
||||
fails.append(f"① auth_str 无应答(等待 {dt if dt else '>75'}s)")
|
||||
|
||||
if auth_str:
|
||||
print("\n=== ② login 延迟实测 ===")
|
||||
@ -66,6 +91,8 @@ if auth_str:
|
||||
t0 = send({"act": "login", "msg_id": 2, "dev_id": IMEI, "sign": sign, "timestamp": ts})
|
||||
dt, resp = wait_reply(t0)
|
||||
print(f" {'✅' if resp else '❌'} 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False) if resp else '无'}")
|
||||
if not resp:
|
||||
fails.append(f"② login 无应答(等待 {dt if dt else '>75'}s)")
|
||||
|
||||
print("\n=== ③ status_post 延迟实测 ===")
|
||||
t0 = send({"act": "status_post", "msg_id": 3, "rssi": 85,
|
||||
@ -74,5 +101,16 @@ if auth_str:
|
||||
{"board": 2, "channels": ["008200000000000000", "008200000000000000"]}]})
|
||||
dt, resp = wait_reply(t0)
|
||||
print(f" {'✅' if resp else '❌'} 应答耗时 {dt:.1f}s : {json.dumps(resp, ensure_ascii=False) if resp else '无'}")
|
||||
if not resp:
|
||||
fails.append(f"③ status_post 无应答(等待 {dt if dt else '>75'}s)")
|
||||
|
||||
s.close()
|
||||
|
||||
# ⛔ 退出码必须反映结果(口径:0=通过,1=断言失败,2=环境/连接问题)
|
||||
if fails:
|
||||
print("\n未通过项(stderr):", file=sys.stderr)
|
||||
for f in fails:
|
||||
print(f" ❌ {f}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print("\n✅ 三步均有应答")
|
||||
sys.exit(0)
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import os
|
||||
import pymysql
|
||||
import random
|
||||
|
||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
conn = pymysql.connect(
|
||||
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
|
||||
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
|
||||
user='root', password='Hbhyg731024@', database='pms',
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 获取所有项目ID
|
||||
|
||||
@ -1,6 +1,13 @@
|
||||
import os
|
||||
|
||||
import pymysql
|
||||
|
||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
conn = pymysql.connect(
|
||||
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
|
||||
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
|
||||
user='root', password='Hbhyg731024@', database='pms',
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 添加parent_id字段
|
||||
|
||||
40
tools/db.py
40
tools/db.py
@ -2,6 +2,11 @@
|
||||
"""
|
||||
PMS 数据库工具
|
||||
用法:
|
||||
需先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道(默认连 127.0.0.1:13306);
|
||||
可用环境变量 PMS_MYSQL_HOST / PMS_MYSQL_PORT 指向其它可达地址。
|
||||
⚠️ 默认目标是**测试库 `pms_dev`**(可用 PMS_MYSQL_DB 覆盖;取 `pms` = 生产库,
|
||||
脚本会额外打印一行醒目警告)。
|
||||
|
||||
python tools/db.py import <sql文件> # 导入SQL文件
|
||||
python tools/db.py query <SQL语句> # 执行查询
|
||||
python tools/db.py exec <SQL语句> # 执行写入
|
||||
@ -9,20 +14,47 @@ PMS 数据库工具
|
||||
python tools/db.py count # 统计各表数据量
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pymysql
|
||||
|
||||
# 数据库配置
|
||||
# 数据库配置:默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发。
|
||||
# 可用环境变量 PMS_MYSQL_HOST / PMS_MYSQL_PORT / PMS_MYSQL_DB 覆盖。
|
||||
# ⚠️ 默认库是**测试库 `pms_dev`** —— 本工具能执行写操作(`exec`/`import`),
|
||||
# 默认绝不能落在生产库上(历史上文档写 pms_dev、代码却是 pms:以为在测测试库、实际打生产)。
|
||||
PROD_DB = 'pms'
|
||||
DB_CONFIG = {
|
||||
'host': '10.8.0.252',
|
||||
'host': os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
|
||||
'port': int(os.environ.get('PMS_MYSQL_PORT', '13306')),
|
||||
'user': 'root',
|
||||
'password': 'Hbhyg731024@',
|
||||
'database': 'pms',
|
||||
'database': os.environ.get('PMS_MYSQL_DB', 'pms_dev'),
|
||||
'charset': 'utf8mb4',
|
||||
}
|
||||
|
||||
# 连不上时的统一提示(隧道没起是最常见原因,绝不能让它变成一段栈回溯)
|
||||
TUNNEL_HINT = ('连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道'
|
||||
'(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)')
|
||||
|
||||
|
||||
def announce_target():
|
||||
"""每次连接前醒目提示当前目标库(防止"以为在测试库、实际打生产")"""
|
||||
db = DB_CONFIG['database']
|
||||
print(f"当前目标库:{db}(可用 PMS_MYSQL_DB 覆盖)")
|
||||
if db == PROD_DB:
|
||||
print(f"⚠️ 你正在操作正式库 {PROD_DB} —— 写操作(exec/import)前请再次确认;"
|
||||
f"测试库请用 PMS_MYSQL_DB=pms_dev")
|
||||
|
||||
|
||||
def get_conn():
|
||||
return pymysql.connect(**DB_CONFIG)
|
||||
"""建立连接;连不上时打印可读提示并退出(不抛栈回溯)"""
|
||||
announce_target()
|
||||
try:
|
||||
return pymysql.connect(**DB_CONFIG)
|
||||
except pymysql.MySQLError as e:
|
||||
print(f"✗ MySQL 连接失败 {DB_CONFIG['host']}:{DB_CONFIG['port']}:{e}", file=sys.stderr)
|
||||
print(TUNNEL_HINT, file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
def import_sql(filepath):
|
||||
"""导入SQL文件"""
|
||||
|
||||
@ -24,6 +24,9 @@ BRPOP 卡死、`iccid` NULL、`SCHEMA_SQL` 前向引用、测试环境导出 500
|
||||
|
||||
用法
|
||||
----
|
||||
需先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道(默认连 127.0.0.1:13306);
|
||||
`--host/--port` 优先于环境变量 `PMS_MYSQL_HOST/PMS_MYSQL_PORT`。
|
||||
|
||||
python tools/db_schema_diff.py # 默认:pms_dev 与 pms 全列比对
|
||||
python tools/db_schema_diff.py --db pms # 被检查库换成 pms(参照库自动取另一个)
|
||||
python tools/db_schema_diff.py --reference-db pms # 显式指定参照库
|
||||
@ -55,9 +58,11 @@ try:
|
||||
except ImportError: # pragma: no cover - 环境缺依赖时给出可执行的提示
|
||||
die("缺少依赖:pip install pymysql")
|
||||
|
||||
# ---- 默认连接参数(ZeroTier 直连,见 AGENTS.md「数据库连接」)------------------
|
||||
DEFAULT_HOST = "10.8.0.252"
|
||||
DEFAULT_PORT = 3306
|
||||
# ---- 默认连接参数 ----------------------------------------------------------
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发。
|
||||
# 优先级:命令行 --host/--port > 环境变量 PMS_MYSQL_HOST/PMS_MYSQL_PORT > 下面的默认值。
|
||||
DEFAULT_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1")
|
||||
DEFAULT_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306"))
|
||||
DEFAULT_USER = "root"
|
||||
DEFAULT_PASSWORD = "Hbhyg731024@"
|
||||
|
||||
@ -272,7 +277,10 @@ def main():
|
||||
conn_a.close()
|
||||
conn_b.close()
|
||||
except pymysql.MySQLError as e:
|
||||
die("数据库连接/查询失败:%s" % e)
|
||||
# 连不上时必须是 exit 2(连接错误),绝不能落成"无漂移"的 exit 0
|
||||
die("数据库连接/查询失败:%s\n"
|
||||
"连接被拒:请先运行 `pwsh tools/dev-tunnel.ps1` 建立 SSH 隧道"
|
||||
"(或设置 PMS_MYSQL_HOST/PMS_MYSQL_PORT 指向其它可达地址)" % e)
|
||||
|
||||
# ---- TIMESTAMP 巡检(两种模式都做)----
|
||||
ts_columns = scan_timestamp_columns(snapshot)
|
||||
|
||||
28
tools/dev-tunnel.ps1
Normal file
28
tools/dev-tunnel.ps1
Normal file
@ -0,0 +1,28 @@
|
||||
# 开发机 → 生产服务器(SSH) → 阿里云 RDS/Redis
|
||||
# 用途:本地脚本 / DBX / 客户端通过 127.0.0.1:13306(MySQL)、127.0.0.1:16379(Redis) 访问阿里云
|
||||
# 说明:不需要在服务器上开放任何数据库端口(已移除 nginx stream 转发)
|
||||
#
|
||||
# 用法:$env:PMS_SSH_PASSWORD = '<服务器 SSH 口令>'; pwsh tools/dev-tunnel.ps1
|
||||
# 常驻自愈:tools/dev-tunnel-run.ps1(含口令、已 gitignore、登录即起的快捷方式 PMS-DEV-TUNNEL.lnk)
|
||||
# 换机/重装后重建:把上面两行跑一次即可;要开机自启就在启动文件夹放一个指向 dev-tunnel-run.ps1 的快捷方式。
|
||||
param(
|
||||
[string]$Server = '118.31.119.206',
|
||||
[string]$User = 'root',
|
||||
[string]$MySqlHost = 'rm-bp1bb7yiv5n8z4uvp.mysql.rds.aliyuncs.com',
|
||||
[string]$RedisHost = 'r-bp1lsezvgzmgtwhbtg.redis.rds.aliyuncs.com',
|
||||
[int]$LocalMySqlPort = 13306,
|
||||
[int]$LocalRedisPort = 16379
|
||||
)
|
||||
# ⚠️ 日志骗人的历史坑(2026-09-20 已修,勿退回):
|
||||
# 原日志行写成 `"... -> $MySqlHost:3306 ..."`,而 PowerShell 会把 `$MySqlHost:3306`
|
||||
# 解析成**带作用域限定的变量名**(形如 `$env:PATH` 的 `$作用域:名字`)—— 该变量不存在,
|
||||
# 于是**主机名连端口一起渲染成空串**(日志出现 `127.0.0.1:13306 -> ,`),
|
||||
# 而隧道本身完全正常(下面 plink 的参数本来就写了花括号)⇒ 属于"日志与事实不符"。
|
||||
# ✅ 规则:变量后面紧跟 `:` 时必须写成 `"${MySqlHost}:3306"`。
|
||||
# ❌ 这不是 param 默认值的问题(默认值一直生效,已实测 plink 命令行带真实主机名);
|
||||
# 默认值**只在本 param 块定义一份**,别在别处再写第二套(两套默认值必然漂移)。
|
||||
|
||||
$pw = $env:PMS_SSH_PASSWORD
|
||||
if (-not $pw) { Write-Error '请先设置环境变量 PMS_SSH_PASSWORD(服务器 SSH 口令)'; exit 2 }
|
||||
Write-Host "建立隧道:127.0.0.1:${LocalMySqlPort} -> ${MySqlHost}:3306 , 127.0.0.1:${LocalRedisPort} -> ${RedisHost}:6379 (经 ${User}@${Server})"
|
||||
plink -batch -pw $pw -N -L "${LocalMySqlPort}:${MySqlHost}:3306" -L "${LocalRedisPort}:${RedisHost}:6379" "$User@$Server"
|
||||
@ -9,21 +9,23 @@ Redis 模拟数据填充工具
|
||||
python tools/mock_redis.py # 填充模拟数据
|
||||
python tools/mock_redis.py --clear # 清除所有模拟数据
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import random
|
||||
import redis
|
||||
import pymysql
|
||||
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
# Redis 配置
|
||||
REDIS_HOST = "10.8.0.252"
|
||||
REDIS_PORT = 6379
|
||||
REDIS_HOST = os.environ.get("PMS_REDIS_HOST", "127.0.0.1")
|
||||
REDIS_PORT = int(os.environ.get("PMS_REDIS_PORT", "16379"))
|
||||
REDIS_PASSWORD = "Hbhyg731024@"
|
||||
REDIS_DB = 5
|
||||
|
||||
# MySQL 配置
|
||||
MYSQL_HOST = "10.8.0.252"
|
||||
MYSQL_PORT = 3306
|
||||
MYSQL_HOST = os.environ.get("PMS_MYSQL_HOST", "127.0.0.1")
|
||||
MYSQL_PORT = int(os.environ.get("PMS_MYSQL_PORT", "13306"))
|
||||
MYSQL_USER = "root"
|
||||
MYSQL_PASSWORD = "Hbhyg731024@"
|
||||
MYSQL_DB = "pms"
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import os
|
||||
import pymysql
|
||||
import random
|
||||
|
||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
conn = pymysql.connect(
|
||||
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
|
||||
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
|
||||
user='root', password='Hbhyg731024@', database='pms',
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 1. 添加address字段
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import os
|
||||
import pymysql
|
||||
import random
|
||||
|
||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
conn = pymysql.connect(
|
||||
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
|
||||
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
|
||||
user='root', password='Hbhyg731024@', database='pms',
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# ICCID规则:
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import os
|
||||
import pymysql
|
||||
import random
|
||||
|
||||
conn = pymysql.connect(host='10.8.0.252', user='root', password='Hbhyg731024@', database='pms')
|
||||
# 默认指向 SSH 隧道(tools/dev-tunnel.ps1),不再使用已移除的 ZeroTier/nginx 转发
|
||||
conn = pymysql.connect(
|
||||
host=os.environ.get('PMS_MYSQL_HOST', '127.0.0.1'),
|
||||
port=int(os.environ.get('PMS_MYSQL_PORT', '13306')),
|
||||
user='root', password='Hbhyg731024@', database='pms',
|
||||
)
|
||||
cur = conn.cursor()
|
||||
|
||||
# 添加ICCID字段
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user