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;消费链路探测通过。
292 lines
13 KiB
Python
292 lines
13 KiB
Python
#!/usr/bin/env python3
|
||
"""PMS 时间列解码端到端验收(会造临时数据并自动清理)
|
||
|
||
它在验收什么
|
||
------------
|
||
sqlx 的 `chrono::NaiveDateTime` **只与 SQL 类型 `DATETIME` 兼容**(`TIMESTAMP` 只与
|
||
`DateTime<Utc>` 兼容),而本项目时间列在 Rust 侧全是 `NaiveDateTime`/`Option<NaiveDateTime>`
|
||
⇒ 任何一个 `TIMESTAMP` 列都会让对应接口 **500**:
|
||
`error occurred while decoding column "start_time": mismatched types; Rust type
|
||
Option<NaiveDateTime> (as SQL type DATETIME) is not compatible with SQL type TIMESTAMP`
|
||
|
||
已实测的两次事故:测试环境充电记录导出必败;生产 `/api/charge-orders` 一有订单就 500。
|
||
本脚本用"造 1~2 条真实行 → 调接口 → 断言 200 且能返回行 → 清理"的方式,
|
||
把"列定义对齐了吗"从**看日志**升级成**看接口**。
|
||
|
||
为什么放在 test/ 而不是 tools/
|
||
------------------------------
|
||
`tools/` 放的是开发期只读工具(`db.py`、`db_schema_diff.py`),不写任何数据;
|
||
本脚本会往库里**写临时行**、需要**活的 API 与 token**,属于集成/端到端验收用例 ——
|
||
正是 `AGENTS.md` 里 `test/`(测试用例/报告)的定位。
|
||
|
||
用法
|
||
----
|
||
# 测试环境(默认):JWT_SECRET 取 /data/pms_dev/api-server/.env
|
||
python test/schema_alignment_e2e.py --env dev --jwt-secret <JWT_SECRET>
|
||
|
||
# 生产环境:必须显式加 --yes-prod;不新建账号,复用既有超管 id 签 token
|
||
python test/schema_alignment_e2e.py --env prod --jwt-secret <JWT_SECRET> --yes-prod
|
||
|
||
# 只清理上一次的残留
|
||
python test/schema_alignment_e2e.py --env dev --clean-only
|
||
|
||
退出码:0=全部通过;1=有断言失败;2=参数/连接错误
|
||
"""
|
||
|
||
import argparse
|
||
import base64
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import os
|
||
import ssl
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
|
||
try:
|
||
import pymysql
|
||
except ImportError: # pragma: no cover
|
||
print("缺少依赖:pip install pymysql", file=sys.stderr)
|
||
sys.exit(2)
|
||
|
||
# ---- 环境参数 ---------------------------------------------------------------
|
||
ENVS = {
|
||
"dev": {
|
||
"db": "pms_dev",
|
||
"base": "https://test.anzhizhichong.com/api/pms",
|
||
"temp_phone": "19900000003",
|
||
},
|
||
"prod": {
|
||
"db": "pms",
|
||
"base": "https://app.anzhizhichong.com/api/pms",
|
||
"temp_phone": None, # 生产不新建账号
|
||
"admin_user_id": 2, # 复用既有总管理员账号签 token
|
||
"admin_phone": "13912391234",
|
||
},
|
||
}
|
||
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@"
|
||
|
||
# 临时数据标记(清理时按它精确定位,绝不误删业务数据)
|
||
TAG = "VERIFY-SCHEMA-TMP"
|
||
# 必须已是 DATETIME 的列(TIMPESTAMP 会让接口 500)
|
||
DATETIME_REQUIRED = [
|
||
("charge_records", "start_time"),
|
||
("charge_records", "end_time"),
|
||
("charge_orders", "start_time"),
|
||
("charge_orders", "end_time"),
|
||
("charge_orders", "created_at"),
|
||
("device_logs", "created_at"),
|
||
("users", "created_at"),
|
||
("users", "updated_at"),
|
||
("download_tasks", "completed_at"),
|
||
]
|
||
|
||
|
||
def die(message, code=2):
|
||
print(message, file=sys.stderr)
|
||
sys.exit(code)
|
||
|
||
|
||
# ---- 鉴权:按 API 的 Claims {sub, phone, exp, iat} 自签 HS256 ----------------
|
||
def _b64(raw):
|
||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||
|
||
|
||
def sign_jwt(secret, sub, phone):
|
||
now = int(time.time())
|
||
seg = _b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode()) + "." + \
|
||
_b64(json.dumps({"sub": sub, "phone": phone, "exp": now + 1800, "iat": now},
|
||
separators=(",", ":")).encode())
|
||
return seg + "." + _b64(hmac.new(secret.encode(), seg.encode(), hashlib.sha256).digest())
|
||
|
||
|
||
def http_get(base, path, token):
|
||
"""带 Bearer token 的 GET;返回 (状态码, 响应体文本)"""
|
||
req = urllib.request.Request(base + path, headers={"Authorization": "Bearer " + token})
|
||
ctx = ssl.create_default_context()
|
||
ctx.check_hostname = False
|
||
ctx.verify_mode = ssl.CERT_NONE
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=30, context=ctx) as resp:
|
||
return resp.status, resp.read().decode("utf-8", "replace")
|
||
except urllib.error.HTTPError as e:
|
||
return e.code, e.read().decode("utf-8", "replace")
|
||
|
||
|
||
def cleanup(cur, phone):
|
||
"""删掉本脚本造的所有临时数据(幂等,可重复执行)"""
|
||
cur.execute("DELETE FROM download_tasks WHERE file_name LIKE %s OR error_message LIKE %s",
|
||
(f"%{TAG}%", f"%{TAG}%"))
|
||
n_dl = cur.rowcount
|
||
cur.execute("DELETE FROM device_logs WHERE content LIKE %s", (f"%{TAG}%",))
|
||
n_log = cur.rowcount
|
||
cur.execute("DELETE FROM charge_orders WHERE battery_id = %s", (TAG,))
|
||
n_order = cur.rowcount
|
||
n_user = 0
|
||
if phone:
|
||
cur.execute("DELETE FROM users WHERE phone = %s", (phone,))
|
||
n_user = cur.rowcount
|
||
return n_dl, n_log, n_order, n_user
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(
|
||
description="PMS 时间列解码端到端验收(造临时行 → 调接口 → 断言 → 自动清理)",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="退出码:0=全部通过;1=断言失败;2=参数/连接错误",
|
||
)
|
||
ap.add_argument("--env", choices=["dev", "prod"], default="dev", help="目标环境(默认 dev)")
|
||
ap.add_argument("--jwt-secret", help="该环境 api-server 的 JWT_SECRET(或设环境变量 PMS_JWT_SECRET)")
|
||
ap.add_argument("--api-base", help="覆盖 API 基址,如 http://127.0.0.1:3000/api")
|
||
ap.add_argument("--admin-user-id", type=int, help="reuse 模式下的超管 user id(prod 默认 2)")
|
||
ap.add_argument("--yes-prod", action="store_true", help="确认对生产库写临时数据(prod 必填)")
|
||
ap.add_argument("--clean-only", action="store_true", help="只清理临时数据后退出")
|
||
ap.add_argument("--host", default=DEFAULT_HOST)
|
||
ap.add_argument("--port", type=int, default=DEFAULT_PORT)
|
||
ap.add_argument("--user", default=DEFAULT_USER)
|
||
ap.add_argument("--password", default=DEFAULT_PASSWORD)
|
||
args = ap.parse_args()
|
||
|
||
env = ENVS[args.env]
|
||
base = args.api_base or env["base"]
|
||
if args.env == "prod" and not args.yes_prod:
|
||
die("对生产环境跑验收会写临时行:确认请加 --yes-prod(脚本会在结束时自动清理)")
|
||
secret = args.jwt_secret or os.environ.get("PMS_JWT_SECRET")
|
||
if not secret and not args.clean_only:
|
||
die("缺少 --jwt-secret(或环境变量 PMS_JWT_SECRET)\n"
|
||
" 取法:ssh 到服务器读 /data/pms_dev/api-server/.env 或 /data/pms/api-server/.env")
|
||
|
||
try:
|
||
conn = pymysql.connect(host=args.host, port=args.port, user=args.user,
|
||
password=args.password, database=env["db"],
|
||
charset="utf8mb4", autocommit=True)
|
||
except pymysql.MySQLError as 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")
|
||
|
||
if args.clean_only:
|
||
print("清理临时数据:download_tasks/device_logs/charge_orders/users =", cleanup(cur, phone))
|
||
return 0
|
||
|
||
failures = []
|
||
try:
|
||
# ---- 0) 前置检查:这些列必须已经是 DATETIME ----
|
||
print("== 0) 列类型前置检查(%s)==" % env["db"])
|
||
bad_types = []
|
||
for table, column in DATETIME_REQUIRED:
|
||
cur.execute(
|
||
"SELECT DATA_TYPE FROM information_schema.COLUMNS "
|
||
"WHERE TABLE_SCHEMA=%s AND TABLE_NAME=%s AND COLUMN_NAME=%s",
|
||
(env["db"], table, column),
|
||
)
|
||
row = cur.fetchone()
|
||
got = row[0] if row else "<列不存在>"
|
||
flag = "OK" if got == "datetime" else "FAIL"
|
||
if got != "datetime":
|
||
bad_types.append("%s.%s=%s" % (table, column, got))
|
||
print(" [%s] %-30s = %s" % (flag, "%s.%s" % (table, column), got))
|
||
if bad_types:
|
||
failures.append("时间列仍是 TIMESTAMP/非 DATETIME:%s" % ", ".join(bad_types))
|
||
|
||
# ---- 1) 造临时数据(外键需要的真实拓扑:柜子 + 仓体)----
|
||
print("\n== 1) 造临时数据 ==")
|
||
cur.execute("DELETE FROM download_tasks WHERE file_name LIKE %s", (f"%{TAG}%",))
|
||
cur.execute("DELETE FROM device_logs WHERE content LIKE %s", (f"%{TAG}%",))
|
||
cur.execute("DELETE FROM charge_orders WHERE battery_id = %s", (TAG,))
|
||
cur.execute("SELECT c.id, cb.cabinet_id FROM compartments c "
|
||
"JOIN cabin_boards cb ON c.cabin_board_id = cb.id ORDER BY c.id LIMIT 1")
|
||
topo = cur.fetchone()
|
||
if not topo:
|
||
die("库里没有 compartments/cabin_boards,无法造订单(外键需要)", 2)
|
||
compartment_id, cabinet_id = topo
|
||
|
||
if phone:
|
||
cur.execute("DELETE FROM users WHERE phone = %s", (phone,))
|
||
cur.execute("INSERT INTO users (phone,name,password,role,organization_id,status) "
|
||
"VALUES (%s,%s,'x',2,NULL,1)", (phone, TAG))
|
||
admin_id, admin_phone = cur.lastrowid, phone
|
||
print(" 临时超管 id=%s(脚本结束时删除)" % admin_id)
|
||
else:
|
||
admin_id = args.admin_user_id or env["admin_user_id"]
|
||
admin_phone = env["admin_phone"]
|
||
print(" 复用既有超管 id=%s(不新建账号)" % admin_id)
|
||
|
||
# status<>0 ⇒ 生成列 active_flag=NULL,不会撞"同仓体唯一活跃单"
|
||
cur.execute(
|
||
"INSERT INTO charge_orders (compartment_id, cabinet_id, battery_id, start_time, end_time, "
|
||
"start_soc, end_soc, energy, reason, status) "
|
||
"VALUES (%s,%s,%s,NOW(),NOW(),850,1000,22,'normal',1)",
|
||
(compartment_id, cabinet_id, TAG))
|
||
order_id = cur.lastrowid
|
||
cur.execute("INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (%s,1,%s)",
|
||
(cabinet_id, json.dumps({"tag": TAG})))
|
||
log_id = cur.lastrowid
|
||
print(" charge_orders.id=%s device_logs.id=%s(cabinet_id=%s)" % (order_id, log_id, cabinet_id))
|
||
|
||
token = sign_jwt(secret, admin_id, admin_phone)
|
||
|
||
# ---- 2) 端点断言 ----
|
||
print("\n== 2) 接口断言 ==")
|
||
checks = [
|
||
("/charge-orders?page=1&page_size=20", TAG, "订单列表(NaiveDateTime 解码)"),
|
||
("/device-logs?page=1&page_size=20", TAG, "设备日志列表(created_at 解码)"),
|
||
("/charge-records?page=1&page_size=5", None, "充电记录列表"),
|
||
]
|
||
for path, needle, desc in checks:
|
||
status, body = http_get(base, path, token)
|
||
ok = status == 200 and (needle is None or needle in body)
|
||
print(" [%s] GET %-38s HTTP %s %s" % ("OK" if ok else "FAIL", path.split("?")[0], status, desc))
|
||
if not ok:
|
||
failures.append("GET %s -> HTTP %s(期望 200%s):%s"
|
||
% (path, status, "" if needle is None else " 且含临时行", body[:300]))
|
||
|
||
# ---- 3) 导出链路(xlsx 生成)----
|
||
print("\n== 3) 导出链路(GET /api/charge-records/export)==")
|
||
status, body = http_get(base, "/charge-records/export", token)
|
||
if status != 200:
|
||
failures.append("创建导出任务失败:HTTP %s %s" % (status, body[:200]))
|
||
else:
|
||
task_id = json.loads(body)["data"]["task_id"]
|
||
cur.execute("UPDATE download_tasks SET file_name=CONCAT(IFNULL(file_name,''), %s) WHERE id=%s",
|
||
(TAG, task_id)) # 打标记,便于清理
|
||
deadline = time.time() + 60
|
||
row = None
|
||
while time.time() < deadline:
|
||
cur.execute("SELECT status,progress,file_path,IFNULL(error_message,'') "
|
||
"FROM download_tasks WHERE id=%s", (task_id,))
|
||
row = cur.fetchone()
|
||
if row and row[0] in (2, 3):
|
||
break
|
||
time.sleep(2)
|
||
print(" download_tasks.id=%s -> status=%s progress=%s file=%s err=%s"
|
||
% (task_id, row[0], row[1], row[2], row[3]))
|
||
if not row or row[0] != 2 or row[1] != 100 or not row[2]:
|
||
failures.append("导出任务未成功(status/progress/file_path 见上)")
|
||
else:
|
||
print(" ⚠️ 导出文件在 API 服务器上:%s(如需删除请到服务器 exports/ 目录)" % row[2])
|
||
finally:
|
||
print("\n== 4) 清理临时数据 ==")
|
||
n_dl, n_log, n_order, n_user = cleanup(cur, phone)
|
||
print(" download_tasks=%d device_logs=%d charge_orders=%d users=%d" % (n_dl, n_log, n_order, n_user))
|
||
conn.close()
|
||
|
||
print("\n== 结论 ==")
|
||
if failures:
|
||
for f in failures:
|
||
print(" ❌ %s" % f)
|
||
return 1
|
||
print(" ✅ 全部通过:时间列已是 DATETIME,订单/日志/充电记录/导出四个链路的解码不再 500")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|