# -*- coding: utf-8 -*- """pbl_blueprint M1a 自查门禁(重写版,退出码真实反映结果)。 设计原则 -------- 1. 只核验「真实落盘文件」,逐项输出 PASS/FAIL + 证据,禁止凭摘要放行。 2. 有任一 FAIL 或检查组异常 -> exit 1(门禁有效);全 PASS -> exit 0。 3. 判定规则严格对齐规范,修正旧版三处自身缺陷: - CRUD 契约的 editable 读「根级数组」(旧版误读 params 内字段级 bool,恒判空) - 根键要求「必须包含 tblname+params」,并允许规范要求的 editable/browserfields (旧版要求根键严格等于 2 键,与 QC 要求 editable/browserfields 存在自相矛盾) - id str32 判定为 type=str 且 size=32(规范表达),不再要求字面量 "str32" 4. DDL 覆盖按设计定稿判定:核心表独立建表,node/edge/field/rel 等子对象由 泛化单表 pbl_subobject 承载(obj_type 判别 + payload JSON),故不做 11 表 逐一 CREATE TABLE 硬判,改为核验「主表+版本+delta+泛化+锁+fork+模板+审计」 实际建表清单并输出证据。 用法 ---- python3 modules/pbl_blueprint/scripts/selfcheck.py python3 modules/pbl_blueprint/scripts/selfcheck.py --report <输出报告路径> """ from __future__ import annotations import argparse import json import os import re import subprocess import sys SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) REPO_ROOT = os.path.dirname(SCRIPT_DIR) # modules/pbl_blueprint PKG_DIR = os.path.join(REPO_ROOT, "pbl_blueprint") # 包目录 MODELS_DIR = os.path.join(PKG_DIR, "models") JSON_DIR = os.path.join(PKG_DIR, "json") SQL_DIR = os.path.join(PKG_DIR, "sql") SCRIPTS_DIR = os.path.join(REPO_ROOT, "scripts") TESTS_DIR = os.path.join(REPO_ROOT, "tests") SKILL_MD = os.path.join(REPO_ROOT, "skill", "SKILL.md") LOAD_PATH = os.path.join(SCRIPTS_DIR, "load_path.py") PYPROJECT = os.path.join(REPO_ROOT, "pyproject.toml") # 泛化承载:这些 models 表在物理 DDL 中由 pbl_subobject / pbl_subobject_rel 承载 GENERIC_CARRIER = { "pbl_blueprint_node": "pbl_subobject", "pbl_blueprint_edge": "pbl_subobject_rel", } ERRORS_14 = [ "PblBlueprintError", "ERR_OK", "ERR_TENANT_MISSING", "ERR_PARAM_INVALID", "ERR_NOT_FOUND", "ERR_DUPLICATE", "ERR_STATE_INVALID", "ERR_LOCKED", "ERR_FORBIDDEN", "ERR_DB", "ERR_INTERNAL", "ok", "fail", "err", ] SQLOR_WHITELIST = {"C", "U", "D", "R", "I", "sqlExe"} LINES: list[str] = [] N_PASS = 0 N_FAIL = 0 def record(group: str, ok: bool, item: str, evidence: str = "") -> bool: """记录一条核验结果。返回 ok。""" global N_PASS, N_FAIL tag = "[PASS]" if ok else "[FAIL]" if ok: N_PASS += 1 else: N_FAIL += 1 line = "%s %s | %s" % (tag, group, item) if evidence: line += " -> %s" % evidence LINES.append(line) print(line) return ok def check(group: str, ok: bool, item: str, evidence: str = "") -> bool: return record(group, bool(ok), item, evidence) def _num(v, default=0): """容错取数:size 可能是 int / 数字串 / [18,2] 列表(取首元素)。""" if isinstance(v, (list, tuple)): v = v[0] if v else default if isinstance(v, bool): return default if isinstance(v, (int, float)): return int(v) if isinstance(v, str): m = re.search(r"\d+", v) return int(m.group()) if m else default return default def read(path: str) -> str: try: with open(path, "r", encoding="utf-8") as f: return f.read() except OSError: return "" def load_json(path: str): try: with open(path, "r", encoding="utf-8") as f: return json.load(f), None except Exception as e: # noqa: BLE001 return None, str(e) def py_files(exclude_dirs=()): out = [] for base, dirs, files in os.walk(REPO_ROOT): dirs[:] = [d for d in dirs if d not in (".git", "__pycache__")] rel = os.path.relpath(base, REPO_ROOT) if any(rel == d or rel.startswith(d + os.sep) for d in exclude_dirs): continue for fn in files: if fn.endswith(".py"): out.append(os.path.join(base, fn)) return sorted(out) def rel(path: str) -> str: return os.path.relpath(path, REPO_ROOT).replace(os.sep, "/") # ============================================================== A. 目录结构 def group_a() -> None: g = "A.目录结构" check(g, os.path.isdir(PKG_DIR), "包目录 modules/pbl_blueprint/pbl_blueprint/ 存在", rel(PKG_DIR)) check(g, os.path.isfile(os.path.join(PKG_DIR, "__init__.py")), "包 __init__.py 存在", rel(os.path.join(PKG_DIR, "__init__.py"))) check(g, os.path.isfile(os.path.join(PKG_DIR, "init.py")), "挂载入口 init.py 存在", rel(os.path.join(PKG_DIR, "init.py"))) check(g, os.path.isfile(PYPROJECT), "pyproject.toml 存在", rel(PYPROJECT)) check(g, os.path.isfile(LOAD_PATH), "scripts/load_path.py 存在", rel(LOAD_PATH)) check(g, os.path.isfile(SKILL_MD), "skill/SKILL.md 存在", rel(SKILL_MD)) check(g, os.path.isdir(MODELS_DIR), "models/ 表定义目录存在", rel(MODELS_DIR)) check(g, os.path.isdir(JSON_DIR), "json/ CRUD 契约目录存在", rel(JSON_DIR)) # 违禁替代文件(module.json / conf/rp.json / 模块级 app.py / build.sh) bad = [] for name in ("module.json", "app.py", "build.sh", "Dockerfile"): if os.path.isfile(os.path.join(REPO_ROOT, name)): bad.append(name) if os.path.isfile(os.path.join(REPO_ROOT, "conf", "rp.json")): bad.append("conf/rp.json") check(g, not bad, "无违禁替代文件(module.json/app.py/build.sh/Dockerfile/conf/rp.json)", "命中=%s" % (bad or "无")) # ============================================================== B. 表定义四段式 def group_b() -> None: g = "B.表定义" files = sorted(f for f in os.listdir(MODELS_DIR) if f.endswith(".json")) \ if os.path.isdir(MODELS_DIR) else [] check(g, len(files) == 11, "models/*.json 表定义数 = 11", "实际=%d %s" % (len(files), files)) for fn in files: tbl = fn[:-5] data, errx = load_json(os.path.join(MODELS_DIR, fn)) if data is None: record(g, False, "%s JSON 可解析" % tbl, "解析失败: %s" % errx) continue # 四段式 segs = ["summary", "fields", "indexes", "codes"] miss = [s for s in segs if s not in data] check(g, not miss, "%s 四段式 summary/fields/indexes/codes 齐备" % tbl, "缺失=%s" % (miss or "无")) fields = data.get("fields") or {} # primary = ["id"] prim = data.get("primary") check(g, prim == ["id"], "%s primary=[\"id\"]" % tbl, "实际=%s" % (prim,)) # id str32 idf = fields.get("id") or {} id_ok = (idf.get("type") == "str" and _num(idf.get("size")) == 32) check(g, id_ok, "%s id 为 str32" % tbl, "type=%s size=%s" % (idf.get("type"), idf.get("size"))) # tenant_id 为首业务字段 keys = [k for k in fields.keys() if k != "id"] check(g, bool(keys) and keys[0] == "tenant_id", "%s tenant_id 为首业务字段" % tbl, "首业务字段=%s" % (keys[0] if keys else None)) tf = fields.get("tenant_id") or {} check(g, tf.get("type") == "str" and _num(tf.get("size")) == 32 and tf.get("notnull") is True, "%s tenant_id str32 notnull" % tbl, "type=%s size=%s notnull=%s" % (tf.get("type"), tf.get("size"), tf.get("notnull"))) # 金额字段 double(18,2) money = [k for k, v in fields.items() if isinstance(v, dict) and re.search(r"(amount|price|fee|money|cost|score)$", k)] bad_money = [] for k in money: v = fields[k] sz = v.get("size") sc_ = v.get("scale", v.get("decimal")) # double(18,2) 的三种合法写法:size=[18,2] / size=18+scale=2 / precision=18+scale=2 ok_pair = (isinstance(sz, (list, tuple)) and len(sz) == 2 and _num(sz[0]) == 18 and _num(sz[1]) == 2) ok_split = (_num(sz) == 18 and _num(sc_) == 2) ok_prec = (_num(v.get("precision")) == 18 and _num(sc_) == 2) if not (v.get("type") == "double" and (ok_pair or ok_split or ok_prec)): bad_money.append("%s(type=%s,size=%s,scale=%s)" % (k, v.get("type"), sz, sc_)) check(g, not bad_money, "%s 金额字段 double(18,2)" % tbl, "金额字段=%s 不合规=%s" % (money or "无", bad_money or "无")) # ============================================================== C. CRUD 契约 def group_c() -> None: g = "C.CRUD契约" files = sorted(f for f in os.listdir(JSON_DIR) if f.endswith(".json")) \ if os.path.isdir(JSON_DIR) else [] check(g, len(files) == 11, "json/*.json CRUD 契约数 = 11", "实际=%d" % len(files)) for fn in files: tbl = fn[:-5] data, errx = load_json(os.path.join(JSON_DIR, fn)) if data is None: record(g, False, "%s JSON 可解析" % tbl, "解析失败: %s" % errx) continue keys = list(data.keys()) # 根键必须包含 tblname + params;editable/browserfields 为规范要求的附加键 check(g, "tblname" in keys and "params" in keys, "%s 根键含 tblname+params" % tbl, "根键=%s" % keys) extra = [k for k in keys if k not in ("tblname", "params", "editable", "browserfields")] check(g, not extra, "%s 无自创 table/list 等格式" % tbl, "多余根键=%s" % (extra or "无")) check(g, data.get("tblname") == tbl, "%s tblname 与文件名一致" % tbl, "tblname=%s" % data.get("tblname")) ed = data.get("editable") ed_ok = (isinstance(ed, list) and len(ed) == 3 and all(isinstance(x, str) and x.endswith(".dspy") for x in ed)) check(g, ed_ok, "%s editable = 3 个 .dspy URL" % tbl, "editable=%s" % (ed,)) bf = data.get("browserfields") check(g, isinstance(bf, list) and len(bf) > 0, "%s browserfields 非空" % tbl, "browserfields=%s" % (bf,)) params = data.get("params") or {} check(g, isinstance(params, dict) and len(params) > 0, "%s params 非空字段字典" % tbl, "字段数=%d" % len(params)) pk = [k for k in params.keys() if k != "id"] check(g, bool(pk) and pk[0] == "tenant_id", "%s params 首业务字段为 tenant_id" % tbl, "首业务字段=%s" % (pk[0] if pk else None)) # ============================================================== D. 代码质量 def group_d() -> None: g = "D.代码质量" all_py = py_files() biz_py = py_files(exclude_dirs=("scripts", "tests")) check(g, len(biz_py) > 0, "存在业务 .py 源文件", "业务=%d 全量=%d" % (len(biz_py), len(all_py))) # py_compile 全量 r = subprocess.run([sys.executable, "-m", "py_compile"] + all_py, capture_output=True, text=True) check(g, r.returncode == 0, "py_compile 全量编译(%d 文件)" % len(all_py), "rc=%d %s" % (r.returncode, (r.stderr or "").strip()[-300:])) # 禁硬编码库名(仅业务代码;自查脚本的模式串与测试桩形参不算业务硬编码) pat = re.compile(r"(DBNAME\s*=\s*['\"][^'\"]+['\"]|dbname\s*=\s*['\"][^'\"]+['\"]|DB_NAME\s*=\s*['\"][^'\"]+['\"])") hits = [] for p in biz_py: for i, ln in enumerate(read(p).split("\n"), 1): if pat.search(ln): hits.append("%s:%d" % (rel(p), i)) check(g, not hits, "禁硬编码库名(业务代码,排除 scripts/tests)", "命中=%s" % (hits or "无")) # 取库名方式 init_src = read(os.path.join(PKG_DIR, "init.py")) check(g, "get_module_dbname" in init_src, "init.py 通过 ServerEnv().get_module_dbname 取库名", "命中 get_module_dbname=%s" % ("get_module_dbname" in init_src)) # sqlor API 白名单 used = set() viol = [] api_pat = re.compile(r"\bsor\.([A-Za-z_][A-Za-z0-9_]*)\s*\(") for p in biz_py: for i, ln in enumerate(read(p).split("\n"), 1): for m in api_pat.finditer(ln): name = m.group(1) used.add(name) if name not in SQLOR_WHITELIST: viol.append("%s:%d sor.%s" % (rel(p), i, name)) check(g, not viol, "sqlor API 白名单(仅 C/U/D/R/I/sqlExe)", "实际使用=%s 越界=%s" % (sorted(used) or "无", viol or "无")) # 禁编造 ORM 风格 API fake = [] fake_pat = re.compile(r"\bsor\.(save|list|insert|update_one|delete_one|find|query|get)\s*\(") for p in biz_py: for i, ln in enumerate(read(p).split("\n"), 1): if fake_pat.search(ln): fake.append("%s:%d" % (rel(p), i)) check(g, not fake, "无编造 sqlor API(save/list/insert/find…)", "命中=%s" % (fake or "无")) # ============================================================== E. 注册同步 def group_e() -> None: g = "E.注册同步" pkg_init = read(os.path.join(PKG_DIR, "__init__.py")) miss = [s for s in ERRORS_14 if not re.search(r"\b%s\b" % re.escape(s), pkg_init)] check(g, not miss, "__init__.py 导出 errors 14 符号", "缺失=%s" % (miss or "无")) check(g, os.path.isfile(os.path.join(PKG_DIR, "errors.py")), "errors.py 存在(14 符号定义处)", rel(os.path.join(PKG_DIR, "errors.py"))) err_src = read(os.path.join(PKG_DIR, "errors.py")) miss2 = [s for s in ERRORS_14 if not re.search(r"^(class|def)\s+%s\b|^%s\s*=" % (re.escape(s), re.escape(s)), err_src, re.M)] check(g, not miss2, "errors.py 定义 14 符号", "缺失=%s" % (miss2 or "无")) init_src = read(os.path.join(PKG_DIR, "init.py")) check(g, "def load_pbl_blueprint(" in init_src, "init.py 定义 load_pbl_blueprint()", "入口存在=%s" % ("def load_pbl_blueprint(" in init_src)) reg_pats = [ r"env\.[A-Za-z_][A-Za-z0-9_]*\s*=", # env.func = handler r"setattr\s*\(\s*env", # setattr(env, name, fn) r"env\[[^\]]+\]\s*=", # env['func'] = handler r"register[A-Za-z_]*\s*\(", # register_func(...) / env.register(...) r"env\.(?:register|add|set)[A-Za-z_]*\s*\(", r"\bapi\s*=\s*\{", # 返回 api 字典由框架注册 r"\bfuncs\s*=\s*\{", r"ServerEnv\s*\(", ] reg = [] for pat in reg_pats: reg += re.findall(pat, init_src) check(g, len(reg) > 0, "init.py 向 ServerEnv 注册契约函数/挂载 api", "注册命中数=%d 样例=%s" % (len(reg), sorted(set(reg))[:5])) check(g, "load_pbl_blueprint" in pkg_init, "__init__.py 重导出 load_pbl_blueprint(三处同步)", "命中=%s" % ("load_pbl_blueprint" in pkg_init)) # ============================================================== F. RBAC 路径 def group_f() -> None: """以 import load_path 后的运行时 PATHS 元组为权威(不扫源码文本,避免把 生成器模板串 '/api/x/%s.dspy' 误判为通配路径)。""" g = "F.RBAC路径" check(g, os.path.isfile(LOAD_PATH), "load_path.py 存在", rel(LOAD_PATH)) mod = None err = "" try: import importlib.util spec = importlib.util.spec_from_file_location("_lp_m1a", LOAD_PATH) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) except Exception as e: # noqa: BLE001 err = "%s: %s" % (type(e).__name__, e) check(g, mod is not None, "load_path.py 可导入执行", err or "导入成功") if mod is None: return paths = list(getattr(mod, "PATHS", ()) or ()) check(g, len(paths) > 0, "RBAC 注册路径显式枚举(PATHS 元组)", "路径数=%d" % len(paths)) wild = [p for p in paths if "%" in p or "*" in p] check(g, not wild, "RBAC 路径无 %/* 通配符(运行时真实值)", "通配路径=%s" % (wild[:5] or "无")) nonstd = [p for p in paths if not re.fullmatch(r"/api/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\.dspy", p)] check(g, not nonstd, "RBAC 路径均为 /api/<表>/<动作>.dspy 规范字面量", "异常=%s" % (nonstd[:5] or "无")) dup = sorted({p for p in paths if paths.count(p) > 1}) check(g, not dup, "RBAC 路径无重复", "重复=%s" % (dup[:5] or "无")) # 覆盖 json/*.json editable 的 3×11 条(超集合法:支撑表含额外动作) need = set() if os.path.isdir(JSON_DIR): for fn in sorted(os.listdir(JSON_DIR)): if not fn.endswith(".json"): continue d, _e = load_json(os.path.join(JSON_DIR, fn)) for u in ((d or {}).get("editable") or []): if isinstance(u, str) and u.startswith("api/"): need.add("/" + u) missing = sorted(need - set(paths)) check(g, not missing, "load_path.py 覆盖 json/*.json editable 全部路径(超集)", "load_path=%d json_editable=%d 未覆盖=%s" % (len(paths), len(need), missing[:5] or "无")) # 孤儿路径:每条路径的表名必须归属主清单 11 表或支撑表 9 表 main_t = set(getattr(mod, "MAIN_TABLES", ()) or ()) sup_t = set(getattr(mod, "SUPPORT_TABLES", ()) or ()) known = main_t | sup_t check(g, len(main_t) == 11, "load_path.MAIN_TABLES = 设计定稿 11 表", "实际=%d" % len(main_t)) orphan = [] for p in paths: m = re.match(r"/api/([a-z0-9_]+)/", p) if not m or (known and m.group(1) not in known): orphan.append(p) check(g, not orphan, "load_path.py 无孤儿路径(均归属已知表)", "孤儿=%s" % (orphan[:5] or "无")) # 模块自带 validate() 必须通过 vfn = getattr(mod, "validate", None) if callable(vfn): verrs = vfn() or [] check(g, not verrs, "load_path.validate() 自检通过", "错误=%s" % (verrs[:3] or "无")) else: check(g, False, "load_path.validate() 存在", "未定义 validate()") # ============================================================== G. 租户 fail-closed def group_g() -> None: g = "G.租户fail-closed" biz = py_files(exclude_dirs=("scripts", "tests")) src_all = "\n".join(read(p) for p in biz) check(g, "ERR_TENANT_MISSING" in src_all, "业务代码引用 ERR_TENANT_MISSING(缺租户即拒)", "命中=%s" % ("ERR_TENANT_MISSING" in src_all)) check(g, os.path.isfile(os.path.join(PKG_DIR, "tenant.py")), "tenant.py 租户上下文模块存在", rel(os.path.join(PKG_DIR, "tenant.py"))) tsrc = read(os.path.join(PKG_DIR, "tenant.py")) check(g, "tenant_id" in tsrc, "tenant.py 处理 tenant_id", "命中=%s" % ("tenant_id" in tsrc)) # 禁止默认租户兜底 bad = re.findall(r"tenant_id\s*=\s*['\"](?:default|DEFAULT|1|0|test)['\"]", src_all) check(g, not bad, "无默认租户兜底(fail-closed)", "命中=%s" % (bad or "无")) # ============================================================== H. DDL def group_h() -> None: g = "H.DDL" sqls = sorted(f for f in os.listdir(SQL_DIR) if f.endswith(".sql")) if os.path.isdir(SQL_DIR) else [] check(g, len(sqls) >= 2, "sql/ DDL 文件存在(core + subobjects)", "文件=%s" % sqls) ddl = "\n".join(read(os.path.join(SQL_DIR, f)) for f in sqls) tables = sorted(set(re.findall(r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([a-z0-9_]+)`?", ddl, re.I))) check(g, len(tables) > 0, "DDL 含 CREATE TABLE 语句", "建表数=%d" % len(tables)) # 设计定稿 B1~B3 核心表(B4~B11 为独立建表,非泛化单表) need_core = ["pbl_blueprint", "pbl_blueprint_version", "pbl_blueprint_approval"] miss = [t for t in need_core if t not in tables] check(g, not miss, "DDL 覆盖核心表 B1~B3(聚合根/版本/审批)", "缺失=%s" % (miss or "无")) # 设计定稿 B4~B11 八张子对象表必须独立建表 need_sub = ["pbl_learner", "pbl_learning_goal", "pbl_problem", "pbl_driving_question", "pbl_project", "pbl_role", "pbl_mission", "pbl_artifact_def"] miss_sub = [t for t in need_sub if t not in tables] check(g, not miss_sub, "DDL 覆盖子对象表 B4~B11(8 张独立建表)", "缺失=%s 建表总数=%d" % (miss_sub or "无", len(tables))) # models 表 -> 物理承载映射核验 mfiles = sorted(f[:-5] for f in os.listdir(MODELS_DIR) if f.endswith(".json")) \ if os.path.isdir(MODELS_DIR) else [] # 设计定稿:models 主清单 11 表 = DDL 独立建表,逐表必须有 CREATE TABLE uncovered = [t for t in mfiles if t not in tables] check(g, not uncovered, "models 主清单 11 表均有独立 CREATE TABLE 承载", "未承载=%s models=%d ddl建表=%d" % (uncovered or "无", len(mfiles), len(tables))) check(g, "tenant_id" in ddl, "DDL 各表含 tenant_id 列", "命中=%s" % ("tenant_id" in ddl)) # ============================================================== I. git 证据 def group_i() -> None: g = "I.git证据" def git(*args): r = subprocess.run(["git", "-C", REPO_ROOT] + list(args), capture_output=True, text=True) return r.returncode, (r.stdout or "").strip(), (r.stderr or "").strip() rc, out, _ = git("rev-parse", "--is-inside-work-tree") check(g, rc == 0 and out == "true", "模块目录为 git 仓库", "rc=%d out=%s" % (rc, out)) rc, head, _ = git("log", "-1", "--format=%h %s") check(g, rc == 0 and bool(head), "存在本地提交(HEAD)", head[:120]) rc, st, _ = git("status", "--porcelain") dirty = [x for x in st.split("\n") if x.strip()] check(g, not dirty, "工作区 clean(变更已全部提交)", "未提交条目=%d %s" % (len(dirty), dirty[:5])) rc, rem, _ = git("remote", "-v") check(g, "origin" in rem, "配置 origin 远程", rem.split("\n")[0][:100] if rem else "无") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--report", default="", help="报告输出路径") args = ap.parse_args() groups = [ ("A.目录结构", group_a), ("B.表定义", group_b), ("C.CRUD契约", group_c), ("D.代码质量", group_d), ("E.注册同步", group_e), ("F.RBAC路径", group_f), ("G.租户fail-closed", group_g), ("H.DDL", group_h), ("I.git证据", group_i), ] header = "=" * 78 LINES.append(header) LINES.append("pbl_blueprint M1a 自查报告(selfcheck.py 重写版)") LINES.append("仓库根: %s" % REPO_ROOT) LINES.append(header) print("\n".join(LINES[-4:])) for name, fn in groups: LINES.append("-" * 78) print("-" * 78) try: fn() except Exception as e: # noqa: BLE001 import traceback record(name, False, "检查组执行异常", "%s: %s" % (type(e).__name__, e)) LINES.append(traceback.format_exc()) LINES.append(header) total = N_PASS + N_FAIL verdict = "ALL PASS" if N_FAIL == 0 else "HAS FAILURES" LINES.append("结论: %s | PASS=%d FAIL=%d 总=%d" % (verdict, N_PASS, N_FAIL, total)) LINES.append(header) print("\n".join(LINES[-3:])) if args.report: try: os.makedirs(os.path.dirname(os.path.abspath(args.report)), exist_ok=True) with open(args.report, "w", encoding="utf-8") as f: f.write("\n".join(LINES) + "\n") print("报告已写入: %s" % args.report) except OSError as e: print("报告写入失败: %s" % e) # 门禁:有失败项必须非 0 退出 return 0 if N_FAIL == 0 else 1 if __name__ == "__main__": sys.exit(main())