diff --git a/scripts/ddl/base_table_alias_map.json b/scripts/ddl/base_table_alias_map.json new file mode 100644 index 0000000..f54be78 --- /dev/null +++ b/scripts/ddl/base_table_alias_map.json @@ -0,0 +1,45 @@ +{ + "_comment": "声明口径表名 → models 单一真源表名对照;test/deploy 探针请改用 real 名,避免 ERROR 1146 误报为『表缺失』。real=null 表示该声明项本就零表设计。", + "generated_by": "scripts/gen_base_ddl.py", + "base_tables": [ + "appcodes", + "appcodes_kv", + "audit_log", + "organization", + "orgtypes", + "params", + "permission", + "role", + "rolepermission", + "svgicon", + "userrole", + "users" + ], + "mapping": { + "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": null, + "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)" + } + } +} diff --git a/scripts/ddl/base_tables.sql b/scripts/ddl/base_tables.sql new file mode 100644 index 0000000..a98e7be --- /dev/null +++ b/scripts/ddl/base_tables.sql @@ -0,0 +1,147 @@ +-- ===================================================================== +-- 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; + +-- [appbase] appcodes (源: /d/pipeline/pipeline-app/pkgs/appbase/models/appcodes.json) +CREATE TABLE IF NOT EXISTS `appcodes` ( + `id` VARCHAR(32) COMMENT 'id', + `name` VARCHAR(255) COMMENT '编码名称', + `hierarchy_flg` VARCHAR(1) COMMENT '多级标志', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [appbase] appcodes_kv (源: /d/pipeline/pipeline-app/pkgs/appbase/models/appcodes_kv.json) +CREATE TABLE IF NOT EXISTS `appcodes_kv` ( + `id` VARCHAR(32) COMMENT 'id', + `parentid` VARCHAR(32) COMMENT '父id', + `k` VARCHAR(32) COMMENT '键', + `v` VARCHAR(255) COMMENT '值', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_appcodes_kv_idx1` (`parentid`, `k`), + KEY `idx_appcodes_kv_idx2` (`parentid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [appbase] params (源: /d/pipeline/pipeline-app/pkgs/appbase/models/params.json) +CREATE TABLE IF NOT EXISTS `params` ( + `id` VARCHAR(32) COMMENT 'id', + `params_name` VARCHAR(255) COMMENT '参数名称', + `params_value` VARCHAR(4000) COMMENT '参数值', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [appbase] svgicon (源: /d/pipeline/pipeline-app/pkgs/appbase/models/svgicon.json) +CREATE TABLE IF NOT EXISTS `svgicon` ( + `id` VARCHAR(32) COMMENT 'id', + `icon` LONGTEXT COMMENT 'svg内容', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [rbac] audit_log (源: /d/pipeline/pipeline-app/pkgs/rbac/models/audit_log.json) +CREATE TABLE IF NOT EXISTS `audit_log` ( + `id` VARCHAR(32) COMMENT '用户id', + `permid` VARCHAR(32) COMMENT '权限id', + `userid` VARCHAR(32) COMMENT '用户id', + `params_kw` TEXT COMMENT '调用参数', + `exe_date` DATE COMMENT '调用日期', + `exe_timestamp` TIMESTAMP NULL DEFAULT NULL COMMENT '调用时间戳', + `remote_ip` VARCHAR(40) COMMENT '远程IP', + PRIMARY KEY (`id`), + KEY `idx_audit_log_idx1` (`permid`), + KEY `idx_audit_log_idx2` (`userid`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [rbac] organization (源: /d/pipeline/pipeline-app/pkgs/rbac/models/organization.json) +CREATE TABLE IF NOT EXISTS `organization` ( + `id` VARCHAR(32) COMMENT '机构编码', + `orgname` VARCHAR(255) COMMENT '机构名称', + `orgabbr` VARCHAR(100) COMMENT '机构简称', + `alias_name` VARCHAR(255) COMMENT '机构别名', + `contactor` VARCHAR(32) COMMENT '联系人', + `contactor_phone` VARCHAR(100) COMMENT '联系人电话', + `province_id` VARCHAR(32) COMMENT '所在省id', + `city_id` VARCHAR(32) COMMENT '所在城市id', + `distinct_id` VARCHAR(32) COMMENT '所在地区id', + `emailaddress` VARCHAR(256) COMMENT '邮箱', + `address` VARCHAR(400) COMMENT '地址', + `main_business` VARCHAR(1000) COMMENT '主营业务描述', + `orgcode` VARCHAR(100) COMMENT '组织结构代码', + `license_img` VARCHAR(400) COMMENT '营业执照', + `id_img` VARCHAR(400) COMMENT '身份证', + `parentid` VARCHAR(32) COMMENT '父机构id', + `org_type` VARCHAR(32) COMMENT '机构类型', + `sitename` VARCHAR(300) COMMENT '网站域名', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_organization_idx1` (`orgname`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [rbac] orgtypes (源: /d/pipeline/pipeline-app/pkgs/rbac/models/orgtypes.json) +CREATE TABLE IF NOT EXISTS `orgtypes` ( + `id` VARCHAR(32) COMMENT 'id', + `orgid` VARCHAR(32) COMMENT '机构id', + `orgtypeid` VARCHAR(32) COMMENT '机构类型', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [rbac] permission (源: /d/pipeline/pipeline-app/pkgs/rbac/models/permission.json) +CREATE TABLE IF NOT EXISTS `permission` ( + `id` VARCHAR(32) COMMENT '权限id', + `name` VARCHAR(255) COMMENT '名称', + `description` VARCHAR(255) COMMENT '描述', + `parentid` VARCHAR(32) COMMENT '父权限id', + `path` VARCHAR(255) COMMENT '路径', + `icon` VARCHAR(255) COMMENT '图标', + `permtype` VARCHAR(255) COMMENT '类型', + `need_audit` VARCHAR(1) COMMENT '是否审计', + PRIMARY KEY (`id`), + UNIQUE KEY `idx_permission_idx1` (`path`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [rbac] role (源: /d/pipeline/pipeline-app/pkgs/rbac/models/role.json) +CREATE TABLE IF NOT EXISTS `role` ( + `id` VARCHAR(32) COMMENT '角色id', + `orgtypeid` VARCHAR(32) DEFAULT '0' COMMENT '机构类型', + `name` VARCHAR(100) COMMENT '角色名称', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [rbac] rolepermission (源: /d/pipeline/pipeline-app/pkgs/rbac/models/rolepermission.json) +CREATE TABLE IF NOT EXISTS `rolepermission` ( + `id` VARCHAR(32) COMMENT 'id', + `roleid` VARCHAR(32) COMMENT '角色id', + `permid` VARCHAR(32) COMMENT '权限id', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [rbac] userrole (源: /d/pipeline/pipeline-app/pkgs/rbac/models/userrole.json) +CREATE TABLE IF NOT EXISTS `userrole` ( + `id` VARCHAR(32) COMMENT '用户id', + `userid` VARCHAR(32) COMMENT '用户id', + `roleid` VARCHAR(32) COMMENT '角色id', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- [rbac] users (源: /d/pipeline/pipeline-app/pkgs/rbac/models/users.json) +CREATE TABLE IF NOT EXISTS `users` ( + `id` VARCHAR(32) COMMENT '用户id', + `username` VARCHAR(255) COMMENT '用户名', + `password` VARCHAR(255) COMMENT '密码', + `email` VARCHAR(255) COMMENT '邮件地址', + `orgid` VARCHAR(32) COMMENT '所属机构', + `nick_name` VARCHAR(255) COMMENT '显示名', + `address` VARCHAR(255) COMMENT '地址', + `mobile` VARCHAR(255) COMMENT '手机', + `user_status` VARCHAR(1) DEFAULT '0' COMMENT '用户状态', + `created_at` DATE COMMENT '注册日期', + `last_login` TIMESTAMP NULL DEFAULT NULL COMMENT '最后登录', + `login_fail_count` SMALLINT DEFAULT '0' COMMENT '连续失败次数', + `last_login_fail` TIMESTAMP NULL DEFAULT NULL COMMENT '最后登录失败时间', + `sync_from` VARCHAR(32) COMMENT '同步应用id', + PRIMARY KEY (`id`), + KEY `idx_users_idx1` (`orgid`), + UNIQUE KEY `idx_users_idx2` (`username`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/scripts/gen_base_ddl.py b/scripts/gen_base_ddl.py new file mode 100644 index 0000000..afb9e4f --- /dev/null +++ b/scripts/gen_base_ddl.py @@ -0,0 +1,339 @@ +#!/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())