charging-cabinet/.tmp-scope-check/scope_isolation_check.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

403 lines
22 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.

#!/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()