charging-cabinet/.tmp-scope-check/scope_isolation_check.py

403 lines
22 KiB
Python
Raw Normal View History

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
user_scopes(数据范围)隔离实测 — 在测试库 pms_dev + 测试环境 API(:3000) 上跑
覆盖验收 1-6:
1. 造两个项目(同组织)+ 两个用户(同组织、role_level=0)+ 一个总管理员
2. 未配置 scope 的用户 → 能看到两个项目的设备(= 现状,证明向后兼容)
3. 配置 scope 只允许项目 A 的用户 → 只能看到项目 A 的设备(列表 + 详情都测)
4. 总管理员 → 看到全部(不受 scope 影响)
5. 配置一个不属于该组织的项目 id / 组织 id / 不存在的 id → 被 BadRequest 拒绝
6. 清理临时数据(脚本 finally 里无条件执行)
用法:
set PMS_TEST_JWT_SECRET=<测试环境 .env 里的 JWT_SECRET>
python scope_isolation_check.py
"""
import os
import sys
import time
import json
import random
import urllib.request
import urllib.error
import pymysql
import jwt # PyJWT
DB = dict(host="10.8.0.252", user="root", password="Hbhyg731024@", database="pms_dev")
API = os.environ.get("PMS_TEST_API_BASE", "http://10.8.0.252:3000/api")
SECRET = os.environ.get("PMS_TEST_JWT_SECRET", "")
TAG = "SCOPETEST"
SUFFIX = f"{int(time.time()) % 100000:05d}"
PASS, FAIL = [], []
def ok(name, cond, detail=""):
(PASS if cond else FAIL).append(name)
print(f" [{'PASS' if cond else 'FAIL'}] {name} {detail}")
def token(uid, phone):
now = int(time.time())
return jwt.encode({"sub": uid, "phone": phone, "exp": now + 3600, "iat": now},
SECRET, algorithm="HS256")
def call(method, path, tok=None, body=None):
"""返回 (status, json_or_text)"""
req = urllib.request.Request(API + path, method=method)
req.add_header("Content-Type", "application/json")
if tok:
req.add_header("Authorization", "Bearer " + tok)
data = json.dumps(body).encode() if body is not None else None
try:
with urllib.request.urlopen(req, data, timeout=20) as r:
raw = r.read().decode()
return r.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as e:
raw = e.read().decode()
try:
return e.code, json.loads(raw)
except Exception:
return e.code, raw
def main():
if not SECRET:
print("缺少 PMS_TEST_JWT_SECRET 环境变量(取 /data/pms_dev/api-server/.env 的 JWT_SECRET)")
sys.exit(2)
conn = pymysql.connect(**DB, autocommit=True)
cur = conn.cursor()
ids = {}
try:
# ---------------- 0. 预清理(防上次异常退出残留) ----------------
cleanup(cur)
# ---------------- 1. 造夹具 ----------------
print("\n=== [1] 造夹具 ===")
cur.execute("INSERT INTO organizations (name) VALUES (%s)", (f"{TAG}-ORG-{SUFFIX}",))
org = cur.lastrowid
cur.execute("INSERT INTO organizations (name) VALUES (%s)", (f"{TAG}-ORG2-{SUFFIX}",))
org2 = cur.lastrowid
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org, f"{TAG}-A-{SUFFIX}"))
pa = cur.lastrowid
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org, f"{TAG}-B-{SUFFIX}"))
pb = cur.lastrowid
cur.execute("INSERT INTO projects (organization_id, name) VALUES (%s,%s)", (org2, f"{TAG}-C-{SUFFIX}"))
pc = cur.lastrowid
def add_cabinet(pid, seed):
imei = f"86{seed}{random.randint(1000000, 9999999)}"[:15]
# abstract_id 必须给值:h5 设备列表行结构体是 String(DB 可空),
# 留 NULL 会让该接口 500(历史缺陷,非本次改动引入)
cur.execute("INSERT INTO cabinets (project_id, imei, name, status, auth_str, abstract_id) "
"VALUES (%s,%s,%s,1,%s,%s)",
(pid, imei, f"{TAG}-CAB-{seed}", "AbCd1234", f"10-{SUFFIX}{seed}"))
cid = cur.lastrowid
cur.execute("INSERT INTO cabin_boards (cabinet_id, board_id) VALUES (%s,1)", (cid,))
bid = cur.lastrowid
comp = None
for ch in range(1, 7):
cur.execute("INSERT INTO compartments (cabin_board_id, channel_id) VALUES (%s,%s)", (bid, ch))
if ch == 1:
comp = cur.lastrowid
# 每个柜子造 1 条充电订单 + 1 条设备日志 + 1 条充电记录(用于验证列表接口的范围收窄)
cur.execute("INSERT INTO charge_orders (compartment_id, cabinet_id, battery_id, start_soc, energy, status) "
"VALUES (%s,%s,%s,50,100,1)", (comp, cid, f"{TAG}-BAT-{seed}"))
order_id = cur.lastrowid
cur.execute("INSERT INTO device_logs (cabinet_id, log_type, content) VALUES (%s,1,%s)",
(cid, f"{TAG}-LOG-{seed}"))
cur.execute("INSERT INTO charge_records (compartment_id, cabinet_id, energy, status) VALUES (%s,%s,10,0)",
(comp, cid))
return cid, imei, order_id
cab_a, imei_a, order_a = add_cabinet(pa, "1")
cab_b, imei_b, order_b = add_cabinet(pb, "2")
cab_c, imei_c, order_c = add_cabinet(pc, "3")
def add_user(role, organization_id, label):
phone = f"19{random.randint(100000000, 999999999)}"
cur.execute("INSERT INTO users (phone, name, password, role, organization_id, status) "
"VALUES (%s,%s,%s,%s,%s,1)",
(phone, f"{TAG}-{label}-{SUFFIX}", "not-a-real-hash", role, organization_id))
return cur.lastrowid, phone
u_open, ph_open = add_user(0, org, "OPEN") # 未配置 scope
u_a, ph_a = add_user(0, org, "SCOPEA") # 配 scope=项目A
u_admin, ph_admin = add_user(2, None, "ADMIN") # 总管理员
ids = dict(org=org, org2=org2, pa=pa, pb=pb, pc=pc,
cab_a=cab_a, cab_b=cab_b, cab_c=cab_c,
order_a=order_a, order_b=order_b, order_c=order_c,
u_open=u_open, u_a=u_a, u_admin=u_admin)
print(f" org={org} org2={org2} projA={pa} projB={pb} projC(org2)={pc}")
print(f" cabA={cab_a}/{imei_a} cabB={cab_b}/{imei_b} cabC={cab_c}/{imei_c}")
print(f" user_open={u_open} user_scopeA={u_a} admin={u_admin}")
t_open = token(u_open, ph_open)
t_a = token(u_a, ph_a)
t_admin = token(u_admin, ph_admin)
# ---------------- 2. 未配置 scope = 不限制(向后兼容) ----------------
print("\n=== [2] 未配置 scope 的用户 → 不限制(两个项目都可见)===")
st, body = call("GET", "/cabinets/filter?page_size=100", t_open)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
print(f" GET /cabinets/filter -> {st} total={body['data']['total'] if st == 200 else body}")
ok("未配置 scope 可见项目A设备", cab_a in got)
ok("未配置 scope 可见项目B设备", cab_b in got)
ok("未配置 scope 不可见跨组织(项目C)设备", cab_c not in got)
# ---------------- 5. 非法 scope_id 必须 400 ----------------
print("\n=== [5] 非法 scope_id(跨组织/不存在)→ BadRequest ===")
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pc, "permission_type": 1}]})
print(f" PUT scopes(项目C, 属组织2) -> {st} {body}")
ok("跨组织项目被拒 400", st == 400 and "不属于该用户所属组织" in str(body))
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": 999999999, "permission_type": 1}]})
print(f" PUT scopes(不存在项目) -> {st} {body}")
ok("不存在项目被拒 400", st == 400 and "不存在" in str(body))
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 1, "scope_id": org2, "permission_type": 1}]})
print(f" PUT scopes(组织级=别人的组织) -> {st} {body}")
ok("组织级跨组织被拒 400", st == 400)
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 9}]})
print(f" PUT scopes(permission_type=9) -> {st} {body}")
ok("非法 permission_type 被拒 400", st == 400)
st, body = call("PUT", "/users/999999999/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
print(f" PUT scopes(用户不存在) -> {st} {body}")
ok("目标用户不存在 -> 404", st == 404)
# ---------------- 3. 配置 scope=项目A → 收窄 ----------------
print("\n=== [3] 配置 scope 只允许项目 A → 只能看到项目 A ===")
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
print(f" PUT /users/{u_a}/scopes {{项目A}} -> {st} {body}")
ok("配置项目范围成功", st == 200)
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
print(f" GET /cabinets/filter -> {st} total={body['data']['total'] if st == 200 else body}")
print(f" data ids = {got}")
ok("列表:项目A设备可见", cab_a in got)
ok("列表:项目B设备不可见", cab_b not in got)
ok("列表:跨组织设备不可见", cab_c not in got)
st, body = call("GET", f"/cabinets/{cab_a}", t_a)
print(f" GET /cabinets/{cab_a}(范围内详情) -> {st} data.id={body['data']['id'] if st == 200 else body}")
ok("详情:范围内设备 200", st == 200 and body["data"]["id"] == cab_a)
st, body = call("GET", f"/cabinets/{cab_b}", t_a)
print(f" GET /cabinets/{cab_b}(范围外详情) -> {st} {body}")
ok("详情:范围外设备被拒 403", st == 403)
st, body = call("GET", f"/cabinets/{cab_c}", t_a)
print(f" GET /cabinets/{cab_c}(跨组织详情) -> {st} {body}")
ok("详情:跨组织设备被拒 403", st == 403)
st, body = call("GET", f"/projects/{pb}/cabinets", t_a)
print(f" GET /projects/{pb}/cabinets(范围外项目的设备列表) -> {st} {body}")
ok("范围外项目的设备列表被拒 403", st == 403)
st, body = call("GET", f"/projects/{pa}/cabinets", t_a)
print(f" GET /projects/{pa}/cabinets(范围内项目) -> {st} rows={len(body['data']) if st == 200 else body}")
ok("范围内项目设备列表 200", st == 200 and len(body["data"]) == 1)
st, body = call("GET", "/cabinets/realtime-status?imei=" + imei_b, t_a)
print(f" GET /cabinets/realtime-status?imei=(项目B) -> {st} {body}")
ok("范围外设备实时状态被拒 403", st == 403)
# 订单/记录/日志/能耗 列表接口(叠加了范围条件)—— 必须只出现项目 A 的数据
orders_filter_failed = False
st_filter_failed = False
st, body = call("GET", "/charge-orders?page_size=50", t_a)
order_ids = [o["id"] for o in body["data"]["list"]] if st == 200 else []
print(f" GET /charge-orders -> {st} total={body['data']['total'] if st == 200 else body} ids={order_ids}")
if st != 200:
# 诊断:换成「总管理员」再打一次。若同样 500 ⇒ 与数据范围无关(环境/结构问题)
st_admin, body_admin = call("GET", "/charge-orders?page_size=50", t_admin)
print(f" [诊断] 总管理员 GET /charge-orders -> {st_admin} {body_admin}")
ok("订单 500 与数据范围无关(总管理员同样失败)", st_admin == 500)
orders_filter_failed = True
else:
ok("订单列表可访问", st == 200)
ok("订单列表:项目A订单可见", order_a in order_ids)
ok("订单列表:项目B订单不可见", order_b not in order_ids)
ok("订单列表:跨组织订单不可见", order_c not in order_ids)
st, body = call("GET", "/charge-records?page_size=50", t_a)
rec_cabs = [r["cabinet_id"] for r in body["data"]["list"]] if st == 200 else []
print(f" GET /charge-records -> {st} total={body['data']['total'] if st == 200 else body} cabinets={rec_cabs}")
ok("充电记录列表可访问", st == 200)
ok("充电记录:仅项目A柜子", cab_a in rec_cabs and cab_b not in rec_cabs and cab_c not in rec_cabs)
st, body = call("GET", "/device-logs?page_size=50", t_a)
log_cabs = [r["cabinet_id"] for r in body["data"]["list"]] if st == 200 else []
print(f" GET /device-logs -> {st} total={body['data']['total'] if st == 200 else body} cabinets={log_cabs}")
if st != 200:
st_admin, body_admin = call("GET", "/device-logs?page_size=50", t_admin)
print(f" [诊断] 总管理员 GET /device-logs -> {st_admin} {body_admin}")
ok("设备日志 500 与数据范围无关(总管理员同样失败)", st_admin == 500)
st_filter_failed = True
else:
ok("设备日志列表可访问", st == 200)
ok("设备日志:仅项目A柜子", cab_a in log_cabs and cab_b not in log_cabs and cab_c not in log_cabs)
# —— 端点因历史 TIMESTAMP/DATETIME 漂移 500 时,直接用「实现生成的同一条 SQL」在库里验证收窄 ——
if st_filter_failed or orders_filter_failed:
print("\n [SQL 级验证] 用实现生成的同一条 WHERE 条件直接查库:")
sql_orders = (
"SELECT co.id FROM charge_orders co LEFT JOIN cabinets cab ON co.cabinet_id = cab.id WHERE 1=1"
" AND co.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id"
" WHERE p.organization_id = %s)"
" AND co.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (%s))"
)
cur.execute(sql_orders, (org, pa))
got_orders = [r[0] for r in cur.fetchall()]
print(f" 订单 SQL(组织={org} ∧ 项目={pa}) -> {got_orders}")
ok("SQL 级:订单只命中项目A", order_a in got_orders and order_b not in got_orders and order_c not in got_orders)
sql_logs = (
"SELECT dl.cabinet_id FROM device_logs dl WHERE 1=1"
" AND dl.cabinet_id IN (SELECT c.id FROM cabinets c JOIN projects p ON c.project_id = p.id"
" WHERE p.organization_id = %s)"
" AND dl.cabinet_id IN (SELECT c.id FROM cabinets c WHERE c.project_id IN (%s))"
)
cur.execute(sql_logs, (org, pa))
got_logs = [r[0] for r in cur.fetchall()]
print(f" 设备日志 SQL(组织={org} ∧ 项目={pa}) -> {got_logs}")
ok("SQL 级:设备日志只命中项目A柜子", cab_a in got_logs and cab_b not in got_logs and cab_c not in got_logs)
for path in ("/energy-stats/summary", "/energy-stats/projects", "/energy-stats/cabinets", "/energy-stats/trend"):
st, body = call("GET", path, t_a)
print(f" GET {path} -> {st} {json.dumps(body, ensure_ascii=False)[:160] if st == 200 else body}")
ok(f"范围内用户可访问 {path}", st == 200)
st, body = call("GET", "/energy-stats/cabinets", t_a)
rank_ids = [r["cabinet_id"] for r in body["data"]] if st == 200 else []
print(f" 能耗排行 cabinet_ids = {rank_ids}")
ok("能耗排行:仅项目A柜子", cab_a in rank_ids and cab_b not in rank_ids and cab_c not in rank_ids)
# 小程序设备列表(h5)
st, body = call("GET", "/cabinets", t_a)
ids_h5 = [c["id"] for c in body["data"]] if st == 200 else []
print(f" GET /cabinets (小程序列表) -> {st} ids={ids_h5}")
ok("小程序列表:项目A可见", cab_a in ids_h5)
ok("小程序列表:项目B不可见", cab_b not in ids_h5)
# 写操作:范围外设备指令下发(设备必然离线,但归属校验在更前面)
st, body = call("POST", "/charge/start", t_a, {"compartment_id": 0})
print(f" POST /charge/start (空仓体) -> {st} {body}")
ok("写操作接口有归属校验路径(非 500)", st in (400, 403, 404))
# ---------------- 3b. 组织级 scope = 自己组织(不额外收窄) ----------------
print("\n=== [3b] 组织级 scope = 本人所属组织(合法,且不额外收窄)===")
st, body = call("PUT", f"/users/{u_a}/scopes", t_admin,
{"scopes": [{"scope_type": 1, "scope_id": org, "permission_type": 1}]})
print(f" PUT scopes(组织级=本组织) -> {st} {body}")
ok("组织级=本组织 被接受", st == 200)
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
ok("组织级范围下项目A仍可见", cab_a in got)
ok("组织级范围下项目B仍可见(组织级=整个组织)", cab_b in got)
# ---------------- 3c. 脏数据:一条有效都没有 → 不锁死 ----------------
print("\n=== [3c] 库内脏数据(指向不存在/跨组织/设备级)→ 忽略且不锁死 ===")
st, _ = call("PUT", f"/users/{u_a}/scopes", t_admin, {"scopes": []})
ok("清空范围成功", st == 200)
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,2,%s,1)",
(u_a, 999999999))
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,2,%s,1)",
(u_a, pc))
cur.execute("INSERT INTO user_scopes (user_id, scope_type, scope_id, permission_type) VALUES (%s,3,%s,1)",
(u_a, cab_b))
st, body = call("GET", "/cabinets/filter?page_size=100", t_a)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
print(f" GET /cabinets/filter(脏数据下) -> {st} ids={got}")
ok("脏数据全部被忽略后按「不限制」处理(项目B可见,不锁死)", cab_a in got and cab_b in got)
st, body = call("GET", f"/users/{u_a}/scopes", t_admin)
print(f" GET /users/{u_a}/scopes -> {st} {body['data'] if st == 200 else body}")
# 3 条脏数据:不存在项目 / 跨组织项目 / 设备级(设备级**永不参与鉴权** ⇒ valid=false)
ok("脏数据被标记 valid=false",
st == 200 and len(body["data"]) == 3 and all(not s["valid"] for s in body["data"]))
cur.execute("DELETE FROM user_scopes WHERE user_id=%s", (u_a,))
# ---------------- 4. 总管理员豁免 ----------------
print("\n=== [4] 总管理员 → 看到全部(不受 scope 影响)===")
st, body = call("PUT", f"/users/{u_admin}/scopes", t_admin,
{"scopes": [{"scope_type": 2, "scope_id": pa, "permission_type": 1}]})
print(f" (故意给总管理员配 scope=项目A) PUT scopes -> {st} {body}")
st, body = call("GET", "/cabinets/filter?page_size=100", t_admin)
got = [c["id"] for c in body["data"]["data"]] if st == 200 else []
ok("总管理员仍可见项目A设备", cab_a in got)
ok("总管理员仍可见项目B设备(豁免)", cab_b in got)
ok("总管理员仍可见跨组织设备(豁免)", cab_c in got)
st, body = call("GET", f"/cabinets/{cab_b}", t_admin)
ok("总管理员可看任意设备详情", st == 200)
finally:
# ---------------- 6. 清理 ----------------
print("\n=== [6] 清理临时数据 ===")
try:
cleanup(cur)
print(" 已清理(scopes/users/cabinets/boards/compartments/projects/organizations)")
cur.execute(f"SELECT COUNT(*) FROM organizations WHERE name LIKE '{TAG}%'")
print(" 残留组织数 =", cur.fetchone()[0])
cur.execute(f"SELECT COUNT(*) FROM users WHERE name LIKE '{TAG}%'")
print(" 残留用户数 =", cur.fetchone()[0])
cur.execute(f"SELECT COUNT(*) FROM projects WHERE name LIKE '{TAG}%'")
print(" 残留项目数 =", cur.fetchone()[0])
cur.execute(f"SELECT COUNT(*) FROM cabinets WHERE name LIKE '{TAG}%'")
print(" 残留设备数 =", cur.fetchone()[0])
except Exception as e: # noqa: BLE001
print(" 清理失败:", e)
cur.close()
conn.close()
print(f"\n=== 结果:{len(PASS)} passed / {len(FAIL)} failed ===")
if FAIL:
print("FAILED:", FAIL)
sys.exit(1)
def cleanup(cur):
"""按名称前缀清理本脚本造的数据(幂等)"""
cur.execute(f"SELECT id FROM users WHERE name LIKE '{TAG}%'")
uids = [r[0] for r in cur.fetchall()]
if uids:
fmt = ",".join(["%s"] * len(uids))
cur.execute(f"DELETE FROM user_scopes WHERE user_id IN ({fmt})", uids)
cur.execute(f"DELETE FROM user_roles WHERE user_id IN ({fmt})", uids)
cur.execute(f"DELETE FROM users WHERE id IN ({fmt})", uids)
cur.execute(f"SELECT id FROM cabinets WHERE name LIKE '{TAG}%'")
cids = [r[0] for r in cur.fetchall()]
if cids:
fmt = ",".join(["%s"] * len(cids))
# 先清依赖业务数据(订单/记录/日志/仓体/仓板),再删柜子,避免外键报错
cur.execute(f"DELETE FROM charge_orders WHERE cabinet_id IN ({fmt})", cids)
cur.execute(f"DELETE FROM charge_records WHERE cabinet_id IN ({fmt})", cids)
cur.execute(f"DELETE FROM device_logs WHERE cabinet_id IN ({fmt})", cids)
cur.execute(f"DELETE FROM compartments WHERE cabin_board_id IN "
f"(SELECT id FROM cabin_boards WHERE cabinet_id IN ({fmt}))", cids)
cur.execute(f"DELETE FROM cabin_boards WHERE cabinet_id IN ({fmt})", cids)
cur.execute(f"DELETE FROM cabinets WHERE id IN ({fmt})", cids)
cur.execute(f"SELECT id FROM projects WHERE name LIKE '{TAG}%'")
pids = [r[0] for r in cur.fetchall()]
if pids:
fmt = ",".join(["%s"] * len(pids))
cur.execute(f"DELETE FROM projects WHERE id IN ({fmt})", pids)
cur.execute(f"DELETE FROM organizations WHERE name LIKE '{TAG}%'")
if __name__ == "__main__":
main()