# -*- coding: utf-8 -*- """ pbl_common.tables —— 公共表 DDL(mariadb 方言) 契约(docs/01-design/data-model.md + projects/pbls/env/test.json ddl 段): - 方言 mariadb:主键 `id BIGINT NOT NULL AUTO_INCREMENT` - 禁止 FOREIGN KEY / REFERENCES / ENUM / TIMESTAMP - 时间列一律 DATETIME - 每表首列必须 tenant_id varchar(64) NOT NULL,且索引以 tenant_id 打头 - 编码列 varchar(32) - ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 本模块只声明 pbl_common 自有表(审计 + 幂等种子记录); 业务表 DDL 在各模块 tables.py 与 apps/pbls/scripts/ddl/pbls_tables.sql。 """ from pbl_common.errors import DbError DIALECT = 'mariadb' # 禁用元素(ensure_tables 前自检,命中即抛错,防方言漂移) FORBIDDEN_TOKENS = ('FOREIGN KEY', 'REFERENCES ', 'ENUM(', 'TIMESTAMP', 'BIGSERIAL', 'SERIAL', 'nextval') TABLES = { 'pbl_audit_log': { 'comment': 'PBL 审计日志(append-only,审计独立性)', 'columns': [ ('tenant_id', "varchar(64) NOT NULL COMMENT '租户ID(强制打头)'"), ('action', "varchar(32) NOT NULL COMMENT '动作(白名单 AUDIT_ACTIONS)'"), ('action_raw', "varchar(64) DEFAULT NULL COMMENT '原始动作名(未登记动作留痕)'"), ('resource_type', "varchar(64) DEFAULT NULL COMMENT '资源类型(表名)'"), ('resource_id', "varchar(64) DEFAULT NULL COMMENT '资源主键'"), ('user_id', "varchar(64) DEFAULT NULL COMMENT '操作人'"), ('role', "varchar(64) DEFAULT NULL COMMENT '操作人角色'"), ('session_id', "varchar(128) DEFAULT NULL COMMENT '会话ID'"), ('trace_id', "varchar(64) DEFAULT NULL COMMENT '调用链追踪ID'"), ('result', "varchar(16) NOT NULL DEFAULT 'success' COMMENT '结果 success/fail/deny'"), ('detail', "longtext DEFAULT NULL COMMENT '明细 JSON'"), ('created_at', "datetime NOT NULL COMMENT '创建时间'"), ], 'indexes': [ ('PRIMARY KEY', '(`id`)'), ('KEY `idx_audit_tenant_time`', '(`tenant_id`,`created_at`)'), ('KEY `idx_audit_tenant_res`', '(`tenant_id`,`resource_type`,`resource_id`)'), ('KEY `idx_audit_trace`', '(`tenant_id`,`trace_id`)'), ], }, 'pbl_seed_record': { 'comment': 'PBL 幂等种子注入记录(appcodes/模板/治理种子防重)', 'columns': [ ('tenant_id', "varchar(64) NOT NULL COMMENT '租户ID(* 表示全局种子)'"), ('seed_key', "varchar(128) NOT NULL COMMENT '种子键(模块:组:项)'"), ('seed_group', "varchar(64) DEFAULT NULL COMMENT '种子分组'"), ('module', "varchar(64) NOT NULL COMMENT '所属模块'"), ('payload', "longtext DEFAULT NULL COMMENT '种子内容 JSON'"), ('checksum', "varchar(64) DEFAULT NULL COMMENT '内容校验和(变更检测)'"), ('version', "int NOT NULL DEFAULT 1 COMMENT '注入版本'"), ('status', "varchar(16) NOT NULL DEFAULT 'applied' COMMENT 'applied/skipped/failed'"), ('created_at', "datetime NOT NULL COMMENT '创建时间'"), ('updated_at', "datetime NOT NULL COMMENT '更新时间'"), ], 'indexes': [ ('PRIMARY KEY', '(`id`)'), ('UNIQUE KEY `uk_seed_tenant_key`', '(`tenant_id`,`seed_key`)'), ('KEY `idx_seed_module`', '(`tenant_id`,`module`)'), ], }, } def _assert_dialect(ddl_text, table=None): """方言自检:命中禁用 token 即抛错(mariadb 契约)""" up = ddl_text.upper() for token in FORBIDDEN_TOKENS: if token.upper() in up: raise DbError( message='DDL 含禁用元素 %r(方言必须为 %s,表=%s)' % (token, DIALECT, table), detail={'token': token, 'table': table, 'dialect': DIALECT}, ) return True def ddl_of(table_name): """生成单表 CREATE TABLE 语句(mariadb 方言,IF NOT EXISTS 幂等)""" spec = TABLES.get(table_name) if not spec: raise DbError(message='未登记的表:%s' % table_name, detail={'table': table_name}) cols = spec['columns'] if not cols or cols[0][0] != 'tenant_id': raise DbError( message='表 %s 首列必须为 tenant_id(实际 %s)' % (table_name, cols[0][0] if cols else None), detail={'table': table_name}, ) lines = ["CREATE TABLE IF NOT EXISTS `%s` (" % table_name] lines.append(" `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',") for name, decl in cols: lines.append(" `%s` %s," % (name, decl)) for idx_name, idx_decl in spec['indexes']: lines.append(" %s %s," % (idx_name, idx_decl)) # 去掉最后一行逗号 lines[-1] = lines[-1].rstrip(',') lines.append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='%s';" % spec.get('comment', table_name)) ddl = '\n'.join(lines) _assert_dialect(ddl, table_name) return ddl def all_ddl(): """生成全部公共表 DDL(拼接文本,供 build.sh / apply_ddl.sh 使用)""" return '\n\n'.join(ddl_of(t) for t in sorted(TABLES.keys())) def ensure_tables(conn=None, module='pbl_common'): """ 幂等建表(CREATE TABLE IF NOT EXISTS)。 返回已确保的表名列表。conn 为 None 时自建连接。 """ from pbl_common.dbutil import execute, get_conn own = conn is None conn = conn or get_conn(module=module) created = [] try: for table in sorted(TABLES.keys()): execute(ddl_of(table), conn=conn) created.append(table) if own: try: conn.commit() except Exception: # noqa: BLE001 pass finally: if own: try: conn.close() except Exception: # noqa: BLE001 pass return created def table_names(): """公共表名清单""" return sorted(TABLES.keys())