#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ gen_base_ddl.py —— 基座模块(appbase / rbac)表结构 DDL 生成器 【为什么需要它 / Bug 4SxqQAulkH9eAzEys_h-e 根因】 POST /main/rbac/user/up_login.dspy 返回 500 "Server got itself in trouble"。 该端点(pkgs/rbac/wwwroot/user/up_login.dspy)第一条 SQL 是: dbname = get_module_dbname('rbac') # conf/config.json → 'pbl' async with db.sqlorContext(dbname) as sor: r = await sor.sqlExe('select * from users where username=${username}$', ns) 而 apps/pbls/build.sh 的建表段(第 10 / 10a 节)只覆盖两类表: · scripts/ddl/pbls_tables.sql —— 32 张 pbl_* 业务表 · modules/pbl_*/models/*.json —— 6 张治理/模板域表(10a 打印 "6/6") 基座模块 appbase(appcodes/appcodes_kv/params/svgicon)与 rbac (users/role/permission/rolepermission/userrole/organization/orgtypes/audit_log) 的 models 链**从未被建表**。部署机实测 `SHOW TABLES`=41、其中 pbl_ 前缀 38 张, 非 pbl_ 前缀仅 3 张(appcodes/appcodes_kv/pbl_tenant 类共享表由种子段顺带建出), `users` 不在其列 → 登录端点 sqlExe 抛 ERROR 1146 (42S02) Table 'pbl.users' doesn't exist → ahserver 兜底 500,会话 cookie 不下发 → 42 条业务用例持续 blocked。 同一口径差还解释了 Bug 描述第 6 点的 ERROR 1146 群: pbl_role / pbl_appcode / pbl_validation_rule / pbl_agent_tool 均不存在—— 因为 rbac 的角色表真名是 `role`(见 pkgs/rbac/models/role.json), appbase 的编码表真名是 `appcodes`/`appcodes_kv`, 校验规则集真名是 `pbl_validation_ruleset`,Agent 工具白名单落在 appcodes 组内(零表)。 即:探针用的表名是"声明口径",实库是"models 单一真源口径",两者不一致。 本脚本以 models/*.json 为唯一真源生成 DDL,并额外产出一份 「声明表名 → 真源表名」对照表(scripts/ddl/base_table_alias_map.json), 供 test/deploy 侧探针改用真名,消除同类 1146 误报。 【设计约束】 · 不硬编码基座包路径:三段式探测(环境变量 BASE_PKGS_ROOT → 候选根逐个存在性 校验 → 全部落空则 die),每条派生路径都打印解析出的绝对路径(web-application-spec 铁律 5「路径推导必须自校验」)。 · 不硬编码 DB 连接参数:本脚本只生成 SQL 文件,不连库;连接参数由 build.sh 运行时从 projects/pbls/env/{env}.json 读取(project-directory-spec §8.6)。 · 方言 mariadb:BIGINT AUTO_INCREMENT 口径不适用(基座表主键全是 VARCHAR(32)), 统一 utf8mb4 / utf8mb4_unicode_ci,全部 CREATE TABLE IF NOT EXISTS(幂等)。 · 抽象类型 → 原生类型映射严格按 database-table-definition-spec: 只用 models 里的抽象类型,禁止把原生类型写回 models。 用法: python3 scripts/gen_base_ddl.py \ --out scripts/ddl/base_tables.sql \ --alias-map scripts/ddl/base_table_alias_map.json """ from __future__ import annotations import argparse import json import os import sys # -------------------------------------------------------------------------- # 抽象类型 → MariaDB 原生类型映射(database-table-definition-spec 口径) # -------------------------------------------------------------------------- _INT_TYPES = { "short": "SMALLINT", "int": "INT", "long": "BIGINT", } # 基座包候选根(探测顺序即优先级)。全部为**代码位置**,非 env 连接参数; # 仍逐条打印存在性,避免"路径算错"被误报成"基座包缺失"。 _BASE_PKG_CANDIDATES = ( os.environ.get("BASE_PKGS_ROOT", ""), os.path.join(os.environ.get("WS_DIR", ""), "pkgs"), "/d/pipeline/pipeline-app/pkgs", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "pkgs"), os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "venv", "lib"), ) # 需要建表的基座模块(appbase 提供共享编码表,rbac 提供登录/鉴权表) _BASE_MODULES = ("appbase", "rbac") # rbac 登录流程额外依赖的列,来源 = pkgs/rbac/migration/add_login_tracking.sql # 与 migration/fix_users_schema.sql(up_login.dspy 的锁定检查读 login_fail_count / # last_login_fail,成功登录写 last_login)。models/users.json 未含这三列, # 故在生成 DDL 时补齐,并保证可空(fix_users_schema.sql 明确:NOT NULL DEFAULT # current_timestamp() 会让 "SET last_login_fail=NULL" 被转成当前时间戳)。 _USERS_LOGIN_TRACKING_COLUMNS = ( ("login_fail_count", "INT NOT NULL DEFAULT 0", "连续登录失败次数"), ("last_login_fail", "DATETIME NULL DEFAULT NULL", "最近一次登录失败时间(NULL=从未失败)"), ("last_login", "DATETIME NULL DEFAULT NULL", "最近一次登录成功时间"), ) _EXTRA_COLUMNS = {"users": _USERS_LOGIN_TRACKING_COLUMNS} # 「声明口径表名」→「models 单一真源表名」对照(消除 ERROR 1146 同类误报) _DECLARED_TO_REAL = { "pbl_role": {"real": "role", "module": "rbac", "note": "rbac 角色表真名为 role,非 pbl_role;7 类角色种子写 role + userrole"}, "pbl_appcode": {"real": "appcodes", "module": "appbase", "note": "共享编码父表真名 appcodes(子表 appcodes_kv),非 pbl_appcode"}, "pbl_validation_rule": {"real": "pbl_validation_ruleset", "module": "pbl_validation", "note": "14 维校验规则集真名 pbl_validation_ruleset"}, "pbl_agent_tool": {"real": None, "module": "pbl_agent_runtime", "note": "Agent 工具白名单 13 启用/9 禁用落在 appcodes 组内(零表设计),无独立表"}, "pbl_role_binding": {"real": "pbl_role_binding", "module": "pbl_governance", "note": "治理域角色绑定表,真名一致;行数 0 属种子未注入(见 seed_rbac_admin.py)"}, } def _log(msg: str) -> None: sys.stderr.write("[gen_base_ddl] %s\n" % msg) def _die(msg: str) -> None: sys.stderr.write("[gen_base_ddl][FATAL] %s\n" % msg) raise SystemExit(2) def resolve_base_pkgs_root() -> str: """三段式探测基座包根目录,逐条打印解析结果与存在性。""" for idx, cand in enumerate(_BASE_PKG_CANDIDATES): if not cand: continue abspath = os.path.abspath(cand) ok = os.path.isdir(abspath) and all( os.path.isdir(os.path.join(abspath, m, "models")) for m in _BASE_MODULES ) _log("candidate[%d] BASE_PKGS_ROOT=%s exists=%s models_ok=%s" % (idx, abspath, os.path.isdir(abspath), ok)) if ok: _log("resolved BASE_PKGS_ROOT=%s" % abspath) return abspath _die("未找到含 appbase/models 与 rbac/models 的基座包根;" "请导出 BASE_PKGS_ROOT=<绝对路径> 或 WS_DIR=<机构工作空间> 后重试") return "" # unreachable def _sql_type(field: dict) -> str: """抽象类型 + length/dec → MariaDB 原生类型。""" t = (field.get("type") or "").strip().lower() length = field.get("length") dec = field.get("dec") if t in ("str", "varchar"): n = int(length) if length else 255 return "VARCHAR(%d)" % n if t == "char": n = int(length) if length else 1 return "CHAR(%d)" % n if t in _INT_TYPES: return _INT_TYPES[t] if t in ("float", "decimal"): p = int(length) if length else 15 s = int(dec) if dec else 2 return "DECIMAL(%d,%d)" % (p, s) if t in ("double", "ddouble"): return "DOUBLE" if t == "date": return "DATE" if t == "time": return "TIME" if t == "datetime": return "DATETIME" if t == "timestamp": # 显式 NULL DEFAULT NULL:MariaDB 对表内首个 TIMESTAMP 列会自动加 # DEFAULT CURRENT_TIMESTAMP ON UPDATE,会让幂等种子每次重写时间戳。 return "TIMESTAMP NULL DEFAULT NULL" if t == "text": n = int(length) if length else 0 if n and n <= 65535: return "TEXT" return "LONGTEXT" if t == "bin": return "LONGBLOB" _log("WARN 未知抽象类型 %r(字段 %s),按 VARCHAR(255) 兜底" % (t, field.get("name"))) return "VARCHAR(255)" def _col_ddl(field: dict) -> str: name = field["name"] parts = [" `%s` %s" % (name, _sql_type(field))] nullable = field.get("nullable") # 规范:只有字符串 "no" 渲染 NOT NULL;null:false / 布尔 False 均不识别, # 这里同时兼容遗留写法,避免静默按可空列生成。 if nullable == "no" or nullable is False or field.get("null") is False: parts.append("NOT NULL") default = field.get("default") if default is not None and default != "": dv = str(default) if dv.upper() in ("CURRENT_TIMESTAMP", "NULL"): parts.append("DEFAULT %s" % dv) else: parts.append("DEFAULT '%s'" % dv.replace("'", "''")) title = (field.get("title") or "").replace("'", "''") if title: parts.append("COMMENT '%s'" % title) return " ".join(parts) def load_table_defs(pkgs_root: str): """读取 appbase/rbac 的 models/*.json,返回 [(module, table_name, def_dict)]。""" out = [] for mod in _BASE_MODULES: mdir = os.path.join(pkgs_root, mod, "models") for fn in sorted(os.listdir(mdir)): if not fn.endswith(".json"): continue path = os.path.join(mdir, fn) try: with open(path, "r", encoding="utf-8") as fh: d = json.load(fh) except Exception as exc: # noqa: BLE001 _die("models JSON 解析失败 %s: %s" % (path, exc)) summary = d.get("summary") or [] if not summary: _die("%s 缺 summary 段(四段式硬约束)" % path) s0 = summary[0] tname = s0.get("name") or fn[:-5] primary = s0.get("primary") or ["id"] if isinstance(primary, str): # 规范铁律:primary 必须是数组;字符串会让 DDL 拼出 "i,d" _die("%s summary.primary 是字符串 %r,应为数组" % (path, primary)) out.append((mod, tname, d, primary, path)) _log("loaded %s/models/%s → table=%s primary=%s fields=%d" % (mod, fn, tname, ",".join(primary), len(d.get("fields") or []))) if not out: _die("基座 models 一张表都没读到,拒绝生成空 DDL") return out def render_ddl(defs) -> str: lines = [ "-- =====================================================================", "-- base_tables.sql —— 基座模块(appbase / rbac)表结构,由 scripts/gen_base_ddl.py", "-- 从各模块 models/*.json(单一真源)生成,禁止手工编辑本文件。", "-- 方言: mariadb / utf8mb4 / utf8mb4_unicode_ci;全部 IF NOT EXISTS(幂等)。", "-- 修复: Bug 4SxqQAulkH9eAzEys_h-e(up_login.dspy 500 —— users 表缺失)", "-- Bug 0CQZ7O6ftDRHbxih59upr(种子 4 项 0 行 —— 依赖 role/userrole 就位)", "-- =====================================================================", "SET NAMES utf8mb4;", "", ] for mod, tname, d, primary, path in defs: fields = d.get("fields") or [] if not fields: _die("%s 无 fields 段" % path) names = [f["name"] for f in fields] if len(set(names)) != len(names): _die("%s 字段名重复: %s" % (path, names)) for p in primary: if p not in names: _die("%s 主键 %r 不在 fields 中" % (path, p)) lines.append("-- [%s] %s (源: %s)" % (mod, tname, path)) lines.append("CREATE TABLE IF NOT EXISTS `%s` (" % tname) body = [_col_ddl(f) for f in fields] # 补齐登录跟踪列(仅 users) for extra_name, extra_type, extra_comment in _EXTRA_COLUMNS.get(tname, ()): if extra_name in names: continue body.append(" `%s` %s COMMENT '%s'" % (extra_name, extra_type, extra_comment)) names.append(extra_name) lines.append("-- + 补列 %s(源: rbac/migration/add_login_tracking.sql)" % extra_name) body.append(" PRIMARY KEY (%s)" % ", ".join("`%s`" % p for p in primary)) seen_idx = set() for idx in d.get("indexes") or []: iname = idx.get("name") ifields = idx.get("idxfields") if not iname or not ifields: continue if isinstance(ifields, str): ifields = [ifields] if not all(c in names for c in ifields): _log("WARN %s 索引 %s 含未知列 %s,跳过" % (tname, iname, ifields)) continue # 索引名表内唯一:基座 models 里普遍叫 idx1/idx2,跨表重名在 MySQL 合法, # 但为可读性统一加表名前缀。 uniq = "idx_%s_%s" % (tname, iname) if uniq in seen_idx: continue seen_idx.add(uniq) kind = "UNIQUE KEY" if (idx.get("idxtype") or "index") == "unique" else "KEY" body.append(" %s `%s` (%s)" % (kind, uniq, ", ".join("`%s`" % c for c in ifields))) lines.append(",\n".join(body)) lines.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;") lines.append("") return "\n".join(lines) def render_alias_map(defs) -> dict: real_tables = sorted({t for _, t, _, _, _ in defs}) return { "_comment": "声明口径表名 → models 单一真源表名对照;test/deploy 探针请改用 real 名," "避免 ERROR 1146 误报为『表缺失』。real=null 表示该声明项本就零表设计。", "generated_by": "scripts/gen_base_ddl.py", "base_tables": real_tables, "mapping": _DECLARED_TO_REAL, } def main() -> int: ap = argparse.ArgumentParser(description="生成基座模块(appbase/rbac)DDL") here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ap.add_argument("--out", default=os.path.join(here, "scripts", "ddl", "base_tables.sql")) ap.add_argument("--alias-map", default=os.path.join(here, "scripts", "ddl", "base_table_alias_map.json")) args = ap.parse_args() pkgs_root = resolve_base_pkgs_root() defs = load_table_defs(pkgs_root) ddl = render_ddl(defs) for target in (args.out, args.alias_map): d = os.path.dirname(os.path.abspath(target)) if d and not os.path.isdir(d): os.makedirs(d, exist_ok=True) with open(args.out, "w", encoding="utf-8") as fh: fh.write(ddl) with open(args.alias_map, "w", encoding="utf-8") as fh: json.dump(render_alias_map(defs), fh, ensure_ascii=False, indent=2) fh.write("\n") n_tables = ddl.count("CREATE TABLE IF NOT EXISTS") _log("OK 写出 %s(%d 张表,%d 字节)" % (args.out, n_tables, len(ddl.encode("utf-8")))) _log("OK 写出 %s" % args.alias_map) if n_tables < 8: _die("生成表数 %d < 8,基座 models 读取不完整,拒绝交付" % n_tables) # 硬门禁:users 必须在内,否则登录端点仍会 1146 if "CREATE TABLE IF NOT EXISTS `users`" not in ddl: _die("生成的 DDL 不含 users 表 —— up_login.dspy 500 根因未消除") _log("SELF_CHECK base_ddl: PASS tables=%d users=present" % n_tables) return 0 if __name__ == "__main__": raise SystemExit(main())