charging-cabinet/.tmp-schema-align/endpoint_verify.py
12451 ee92102f3c feat(scope)+fix(db)+feat(security): 数据范围(项目级)生效、库列类型对齐、安全加固、状态位分层展示、能耗实时聚合
- user_scopes 由"假开关"做成生效:粒度到项目级(组织隔离 ∧ 项目∈范围,取交集);未配置=不限制(逐字向后兼容);
  总管理员豁免;脏数据保守忽略不锁死;配置期校验存在性+归属;逐接口叠加(列表/详情/写操作/Excel 导出落 scope_project_ids);
  前端新增"数据范围"弹窗入口。顺带修 GET /api/charge-orders 此前完全没有组织隔离(跨租户泄露)、
  GET /api/projects/{id}/cabinets 的 500
- 数据库列类型系统性对齐:扫描并修复 pms_dev 32 项 / pms 18 项漂移(时间列统一 DATETIME,sqlx NaiveDateTime 只兼容 DATETIME);
  DECIMAL→BIGINT 与列注释口径拉平;修复生产 GET /api/charge-orders、GET /api/device-logs 必然 500 的根因;
  恢复 7 张表丢失的 updated_at ON UPDATE(行为变化,已记入 AGENTS.md);修正 SCHEMA_SQL 前向引用(空库建表必崩);
  新增 tools/db_schema_diff.py(漂移检查,退出码可接部署前置)与 test/schema_alignment_e2e.py
- 安全加固四项:上传去 .svg + 魔数嗅探(堵存储型 XSS);登录失败锁定(5次/15分)+challenge 限流+并发闸门+验证码容差收紧;
  服务降权 root→pms;nginx 安全头 + limit_req + CORS 白名单收敛
- TCP 连接登录门控(上行+下行同一规则):login 前只放行 auth_str/login(PRE_LOGIN_ALLOWED_ACTS)
- 状态位按归属层级展示:柜级 status1/status2 显示在柜子(含柜门 + 开门按钮,独立动作不联动业务)、
  仓体级 batt_status/pow_status 显示在仓体;两端解析函数同源同名
- 能耗报表改实时聚合 charge_records(原 energy_stats 全表只读不写导致报表恒 0)+ 补复合索引
- 组织可见性:新增 GET /api/my-organizations(工厂全量、客户仅本组织)
- 仓体 status 仅表占用,充满/故障由 Redis 通道状态派生
- 订单/占用/队列运维缺口:h5 建单漏单、214 仓体自愈、device:reports 上限、死信 TTL、LimitNOFILE、
  每日 mysqldump + 回滚点保留 5 次
- 文档:AGENTS.md 补层级可见性/数据范围/状态位约定/数据库工具/行为变化;docs/API.md 删除废弃 TCP 章节、接口总表对齐 mod.rs
2026-09-20 02:44:51 +08:00

153 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""端点层验收:造临时数据 → 调接口 → 断言 200 且能返回行 → 清理临时数据
用法:
python .tmp-schema-align/endpoint_verify.py dev # 测试环境(pms_dev + test.anzhizhichong.com)
python .tmp-schema-align/endpoint_verify.py prod # 生产环境(pms + app.anzhizhichong.com)
python .tmp-schema-align/endpoint_verify.py dev clean # 只清理
"""
import base64, hashlib, hmac, json, ssl, sys, time, urllib.request, urllib.error, pymysql
ENVS = {
"dev": dict(
db="pms_dev",
base="https://test.anzhizhichong.com/api/pms",
secret="32e8708743c42fe764aff1d40737a63803a46815a9501f4bcb50c41fe3d27108",
admin_phone="19900000001",
admin_role=2,
),
"prod": dict(
db="pms",
base="https://app.anzhizhichong.com/api/pms",
secret="787789e8eb1f3a10e81596f8219a20aa3c34102acacba2a5ed14bbb1d16ec833",
admin_phone="13912391234",
admin_role=2,
# 生产库**不新建账号**:直接复用既有的总管理员账号(id=2)签 token,
# 只造 charge_orders / device_logs 两条临时业务行,验完立刻删除。
existing_admin_id=2,
),
}
TAG = "VERIFY-SCHEMA-TMP"
def b64(b):
return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def sign(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 call(base, path, token):
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=25, context=ctx) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def main():
env = ENVS[sys.argv[1]]
only_clean = len(sys.argv) > 2 and sys.argv[2] == "clean"
c = pymysql.connect(host="10.8.0.252", user="root", password="Hbhyg731024@",
database=env["db"], autocommit=True)
cur = c.cursor()
# ---- 清理上一轮遗留 ----
creates_admin = "existing_admin_id" not in env
cur.execute("DELETE FROM device_logs WHERE content LIKE %s", (f"%{TAG}%",))
cur.execute("DELETE FROM charge_orders WHERE battery_id = %s", (TAG,))
if creates_admin:
# 只删自己造的临时账号;复用既有账号时**绝不能**碰 users 表
cur.execute("DELETE FROM users WHERE phone = %s", (env["admin_phone"],))
if only_clean:
print("已清理临时数据(device_logs/charge_orders/users)")
return
# ---- 取一个真实拓扑:柜子 + 仓体(满足外键)----
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"
)
row = cur.fetchone()
if not row:
sys.exit("库里没有 compartments,无法造订单(外键需要)")
compartment_id, cabinet_id = row
# ---- 造临时超管(JWT 只带 sub,角色与权限从库里读)----
if creates_admin:
cur.execute(
"INSERT INTO users (phone, name, password, role, organization_id, status) VALUES (%s, %s, 'x', %s, NULL, 1)",
(env["admin_phone"], TAG, env["admin_role"]),
)
admin_id = cur.lastrowid
else:
admin_id = env["existing_admin_id"]
# ---- 造 2 条 charge_orders(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
# ---- 造 2 条 device_logs ----
cur.execute(
"INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (%s, 1, %s)",
(cabinet_id, json.dumps({"tag": TAG, "n": 1})),
)
log_id = cur.lastrowid
print(f"环境={sys.argv[1]} 库={env['db']} 临时数据: users.id={admin_id} "
f"charge_orders.id={order_id} device_logs.id={log_id} (cabinet_id={cabinet_id})")
# ---- 自签 token 调接口 ----
token = sign(env["secret"], admin_id, env["admin_phone"])
results = {}
for path in ("/charge-orders?page=1&page_size=20", "/device-logs?page=1&page_size=20",
"/charge-records?page=1&page_size=5"):
status, body = call(env["base"], path, token)
results[path] = (status, body)
has_tmp = TAG in body
print(f"\n--- GET {path} -> HTTP {status} (临时行出现={has_tmp}) ---")
print(body[:600])
# ---- 断言 ----
ok_orders = results["/charge-orders?page=1&page_size=20"][0] == 200 and TAG in results["/charge-orders?page=1&page_size=20"][1]
ok_logs = results["/device-logs?page=1&page_size=20"][0] == 200 and TAG in results["/device-logs?page=1&page_size=20"][1]
ok_records = results["/charge-records?page=1&page_size=5"][0] == 200
print("\n==== 断言 ====")
print(" /api/charge-orders 200 且返回临时行:", "PASS" if ok_orders else "FAIL")
print(" /api/device-logs 200 且返回临时行:", "PASS" if ok_logs else "FAIL")
print(" /api/charge-records 200 :", "PASS" if ok_records else "FAIL")
print("\n==== 清理临时数据 ====")
cur.execute("DELETE FROM device_logs WHERE content LIKE %s", (f"%{TAG}%",))
print(" device_logs 删除行数:", cur.rowcount)
cur.execute("DELETE FROM charge_orders WHERE battery_id = %s", (TAG,))
print(" charge_orders 删除行数:", cur.rowcount)
if creates_admin:
cur.execute("DELETE FROM users WHERE phone = %s", (env["admin_phone"],))
print(" users 删除行数:", cur.rowcount)
else:
print(" users: 未新建账号(复用既有超管 id=%s),无需删除" % admin_id)
cur.execute("SELECT COUNT(*) FROM charge_orders WHERE battery_id=%s", (TAG,))
print(" 残留 charge_orders:", cur.fetchone()[0])
cur.execute("SELECT COUNT(*) FROM device_logs WHERE content LIKE %s", (f"%{TAG}%",))
print(" 残留 device_logs:", cur.fetchone()[0])
c.close()
sys.exit(0 if (ok_orders and ok_logs and ok_records) else 1)
main()