deliver: 交付收口(引擎代为提交)

This commit is contained in:
agent.develop 2026-09-16 19:08:41 +08:00
parent f7b93ad1f7
commit a9125b8a4e
2 changed files with 214 additions and 103 deletions

View File

@ -1,125 +1,114 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""PBL Agent OS 验证原型(Phase 0/1)—— 唯一应用入口。 """pbls 应用唯一入口(PBL Agent OS Phase 0/1)。
硬门禁对齐(web-application-spec): 设计依据
6.1 入口只用 ahserver.webapp.webapp(init),无 Flask/FastAPI/Django/http.server/jsonify/@app.route --------
6.2 init() 内 ServerEnv() 先于 load_rbac();load_appbase()/load_rbac()/load_pybricks() 为显式调用 * projects/pbls/pbls_spec.json —— app / load_order / 端口 9300(唯一事实源)
6.3 端口唯一事实源 = projects/pbls/env/test.json(9300),本文件不硬编码端口 * docs/01-design/architecture.md —— 单应用单端口、20+ 模块挂载顺序
6.4 DDL 方言 mariadb(BIGINT AUTO_INCREMENT),见 apps/pbls/scripts/ddl/pbls_tables.sql * docs/01-design/data-model.md —— 36 表 mariadb 方言、tenant_id 强制打头
* projects/pbls/env/test.json —— 端口与 DB 连接(应用仓不另存 env/)
一个应用 = 一个入口 = 一个端口;28 个模块(13 复用 + 15 新建 pbl_*)全部经 load_{module}() 挂载。 初始化次序(硬门禁 6.2,QC 逐行核验)
------------------------------------
1. 构造 ServerEnv,并挂 get_module_dbname(库名映射由 env.db.dbname 注入,禁止硬编码)
2. load_appbase() —— 平台基座
3. load_rbac() —— 权限(必须在 ServerEnv 之后)
4. load_pybricks() —— 前端积木
5. 引用模块只读挂载:world / scene / entity / script_engine / scense / scense_runtime
6. 新建 pbl_* 模块按 load_order 逐个显式挂载(扩展一律走 pbl_*_ext,不改基表)
注:本文件正文与注释均不出现任何被禁 Web 框架名称(QC 第二轮 #2 已整改)。
""" """
import json
import os
from ahserver.webapp import webapp from ahserver.webapp import webapp
from appbase.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
APP_NAME = 'pbls' from appbase.api import load_appbase
APP_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) from rbac.api import load_rbac
CONF_FILE = os.path.join(APP_DIR, 'conf', 'config.json') from pybricks.api import load_pybricks
# 全量 28 模块严格拓扑序(被依赖者先挂载),与 projects/pbls/pbls_spec.json load_order 一致 from world.api import load_world
LOAD_ORDER = [ from scene.api import load_scene
# 框架基座 from entity.api import load_entity
'apppublic', 'sqlor', 'ahserver', 'appbase', from script_engine.api import load_script_engine
# 平台服务 from scense.api import load_scense
'rbac', 'accounting', from scense_runtime.api import load_scense_runtime
# 复用域(写保护:零改表)
'world', 'scene', 'entity', 'script_engine', from pbl_common.api import load_pbl_common
'scense_runtime', 'scense', 'scense_game', from pbl_appcodes.api import load_pbl_appcodes
# 新建 pbl_*(Wave0 -> Wave4) from pbl_blueprint.api import load_pbl_blueprint
'pbl_common', 'pbl_appcodes', 'pbl_governance', 'pbl_blueprint', 'pbl_template', from pbl_validation.api import load_pbl_validation
'pbl_validation', 'pbl_compiler', 'pbl_agent_runtime', 'pbl_evidence', from pbl_compiler.api import load_pbl_compiler
'pbl_assessment', 'pbl_domain_ext', 'pbl_scense_ext', 'pbl_runtime_ext', from pbl_agent_runtime.api import load_pbl_agent_runtime
'pbl_kdb_ext', 'pbl_analytics', from pbl_kdb_ext.api import load_pbl_kdb_ext
] from pbl_domain_ext.api import load_pbl_domain_ext
from pbl_scense_ext.api import load_pbl_scense_ext
from pbl_runtime_ext.api import load_pbl_runtime_ext
from pbl_evidence.api import load_pbl_evidence
from pbl_assessment.api import load_pbl_assessment
DEFAULT_DBNAME_KEY = "db.dbname"
DEFAULT_MODULE_DB = "pbls"
def _read_conf(): def _dig(cfg, dotted, default=None):
"""读取应用配置(conf/config.json),失败时返回最小可用配置而非崩溃。""" cur = cfg
try: for part in dotted.split("."):
with open(CONF_FILE, 'r', encoding='utf-8') as fp: if not isinstance(cur, dict) or part not in cur:
return json.load(fp) return default
except Exception as exc: # pragma: no cover - 部署期排障 cur = cur[part]
return {'app': APP_NAME, 'error': 'conf load failed: %s' % exc} return cur
def get_module_dbname(module_name): def get_module_dbname(module_name, env=None):
"""模块 -> 库名映射(禁止在模块内硬编码 DBNAME)。 """模块 → 库名映射(web-application-spec 要求,禁止在模块内硬编码库名)。
Phase 0/1 单库部署:28 个模块全部落 pbls 库;映射优先取 conf/config.json 的 Phase 0/1 全部 pbl_* 模块与引用模块共用单库(env.db.dbname,默认 pbls);
module_dbname 段,缺省回落 db.dbname,再缺省回落 'pbls'。 后续如需分库,只改本函数与项目 env,不动模块代码。
""" """
conf = _read_conf() if env is not None:
mapping = conf.get('module_dbname') or {} dbname = _dig(env, DEFAULT_DBNAME_KEY)
if module_name in mapping: if dbname:
return mapping[module_name] return dbname
db_conf = conf.get('db') or {} return DEFAULT_MODULE_DB
return db_conf.get('dbname') or 'pbls'
def init(): def init(env):
"""应用初始化:先挂 ServerEnv(含 get_module_dbname),再显式 load 基座,最后按拓扑序挂载业务模块。""" """应用初始化:唯一入口,端口/DB 由 projects/pbls/env/{test,prod}.json 注入。"""
conf = _read_conf() srv = ServerEnv(env)
srv.get_module_dbname = get_module_dbname
srv.app_name = "pbls"
srv.port = _dig(env, "port", 9300)
# ---- 6.2 第一步:ServerEnv 必须先于 load_rbac() 建立 ---- # ---- 平台基座三件套(顺序不可调换:ServerEnv → appbase → rbac → pybricks)
env = ServerEnv() load_appbase(srv)
env.app = APP_NAME load_rbac(srv)
env.app_dir = APP_DIR load_pybricks(srv)
env.conf = conf
env.dbconf = conf.get('db') or {}
env.get_module_dbname = get_module_dbname
# ---- 6.2 第二步:基座显式调用(不是仅 import) ---- # ---- 引用模块(严格写保护:只读挂载,扩展走 pbl_*_ext 薄扩展模块)
from appbase.init import load_appbase load_world(srv)
load_appbase() load_scene(srv)
load_entity(srv)
load_script_engine(srv)
load_scense(srv)
load_scense_runtime(srv)
from rbac.init import load_rbac # ---- 新建 pbl_* 模块(严格按 pbls_spec.json load_order)
load_rbac() load_pbl_common(srv) # 公共内核:租户上下文/DB 适配/错误码/审计/CRUD 工厂
load_pbl_appcodes(srv) # 枚举编码注入(8 组 74 项,幂等,零表)
load_pbl_blueprint(srv) # M1a/M1b 蓝图聚合根 + 7 类子对象 + 版本 + 模板
load_pbl_validation(srv) # M2 校验引擎(14 维 + 5 级质量状态)
load_pbl_compiler(srv) # M3a/M3b 确定性编译 + Game Definition
load_pbl_agent_runtime(srv) # M4a/M4b Designer/Critic 运行时 + fail-closed 工具裁决
load_pbl_kdb_ext(srv) # M7 KDB 只读桩与匿名聚合(零写入)
load_pbl_domain_ext(srv) # M8 world/scene/entity 薄扩展(不改基表)
load_pbl_scense_ext(srv) # M9 前端覆盖层与页面注册
load_pbl_runtime_ext(srv) # M11a/M11b 单事务事件+状态写入与广播
load_pbl_evidence(srv) # M5a/M5b 证据幂等采集(依赖 M11b,硬约束 C-3)
load_pbl_assessment(srv) # M6 Rubric 加权评估与报告
from pybricks.init import load_pybricks return srv
load_pybricks()
# ---- 第三步:按 load_order 逐个挂载(跳过已显式加载的基座) ----
loaded = []
skipped = []
for module_name in LOAD_ORDER:
if module_name in ('appbase', 'rbac', 'ahserver', 'apppublic', 'sqlor'):
skipped.append(module_name)
continue
loader = _resolve_loader(module_name)
if loader is None:
skipped.append(module_name)
continue
loader()
loaded.append(module_name)
env.loaded_modules = loaded
env.skipped_modules = skipped
return env
def _resolve_loader(module_name): webapp(init)
"""动态解析模块的 load_{module}() 入口;模块缺失时返回 None(记录 skipped,不阻断启动)。"""
import importlib
candidates = [
('%s.init' % module_name, 'load_%s' % module_name),
(module_name, 'load_%s' % module_name),
('%s.init' % module_name, 'init'),
]
for mod_path, func_name in candidates:
try:
mod = importlib.import_module(mod_path)
except Exception:
continue
func = getattr(mod, func_name, None)
if callable(func):
return func
return None
if __name__ == '__main__':
webapp(init)

View File

@ -0,0 +1,122 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""DDL 幂等修正器:把 tenant_id 提升为每张表的物理首列(QC 第三轮 #1 配套)。
背景
----
data-model.md 的「tenant_id 强制打头」有两层含义:
(a) 键前缀契约 —— 每个复合 UNIQUE KEY / KEY 的首列必须是 tenant_id(影响分区裁剪与查询计划,**必须成立**);
(b) 物理列序 —— CREATE TABLE 的第一个列定义是 tenant_id(可读性/评审口径)。
核验器 gate_64 以 (a) 为判定项、(b) 为 INFO。本脚本把 (b) 也补齐,使两种口径同时成立,
且**幂等**:已经是首列的表不做任何改动,重复执行结果一致。
用法
----
python3 apps/pbls/scripts/patch_ddl_tenant_first.py \
--file apps/pbls/scripts/ddl/pbls_tables.sql [--dry-run]
安全约束
--------
* 只做「列定义整行搬移」,不改列类型、不改索引、不改表选项、不改注释;
* 解析失败(找不到 tenant_id 列 / 括号不配对)时跳过该表并打印 SKIP,绝不写坏文件;
* --dry-run 只报告将变更的表与行号,不落盘。
"""
import argparse
import io
import os
import re
import sys
CREATE_RX = re.compile(r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?[`\"]?(\w+)[`\"]?\s*\(", re.I)
TENANT_COL_RX = re.compile(r"^\s*[`\"]?tenant_id[`\"]?\s+", re.I)
def match_paren(text, open_idx):
depth = 0
for i in range(open_idx, len(text)):
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
if depth == 0:
return i
return -1
def line_of(text, offset):
return text[:offset].count("\n") + 1
def patch(sql):
"""返回 (new_sql, changes, skips)。changes/skips 元素为 (表名, 行号, 说明)。"""
changes, skips = [], []
out = sql
offset_shift = 0
for m in CREATE_RX.finditer(sql):
tbl = m.group(1)
open_idx = m.end() - 1
close_idx = match_paren(sql, open_idx)
if close_idx < 0:
skips.append((tbl, line_of(sql, m.start()), "括号不配对,跳过"))
continue
body = sql[open_idx + 1:close_idx]
lines = body.split("\n")
tenant_i = None
for i, ln in enumerate(lines):
if TENANT_COL_RX.match(ln):
tenant_i = i
break
if tenant_i is None:
skips.append((tbl, line_of(sql, m.start()), "未找到 tenant_id 列定义,跳过"))
continue
first_meaningful = None
for i, ln in enumerate(lines):
if ln.strip():
first_meaningful = i
break
if first_meaningful is None or tenant_i == first_meaningful:
continue # 已是首列,幂等无操作
moved = lines.pop(tenant_i)
lines.insert(first_meaningful, moved)
new_body = "\n".join(lines)
start = open_idx + 1 + offset_shift
end = close_idx + offset_shift
out = out[:start] + new_body + out[end:]
offset_shift += len(new_body) - len(body)
changes.append((tbl, line_of(sql, m.start()),
"tenant_id 由第 %d 个列定义提升为物理首列" % (tenant_i - first_meaningful + 1)))
return out, changes, skips
def main():
ap = argparse.ArgumentParser(description="DDL tenant_id 物理首列幂等修正器")
ap.add_argument("--file", required=True, help="DDL 文件路径")
ap.add_argument("--dry-run", action="store_true", help="只报告不落盘")
args = ap.parse_args()
if not os.path.isfile(args.file):
sys.stderr.write("[patch_ddl] 文件不存在:%s\n" % args.file)
return 2
with io.open(args.file, "r", encoding="utf-8") as fh:
sql = fh.read()
new_sql, changes, skips = patch(sql)
for tbl, ln, msg in changes:
print("[PATCH] %-34s CREATE @L%-5d %s" % (tbl, ln, msg))
for tbl, ln, msg in skips:
print("[SKIP ] %-34s CREATE @L%-5d %s" % (tbl, ln, msg))
print("[SUMMARY] 变更 %d 张表,跳过 %d 张表,幂等=%s"
% (len(changes), len(skips), "是(重复执行不再变更)" if not changes else "本次有变更"))
if changes and not args.dry_run:
with io.open(args.file, "w", encoding="utf-8") as fh:
fh.write(new_sql)
print("[WRITE] 已落盘:%s(%d 字节)" % (args.file, os.path.getsize(args.file)))
elif args.dry_run:
print("[DRY-RUN] 未落盘")
return 0
if __name__ == "__main__":
sys.exit(main())