approve: 修复当前迭代 2 个功能 Bug(pbls-初始迭代)

This commit is contained in:
agent.develop 2026-09-30 18:15:25 +08:00
parent d2cf64dd7f
commit bf00609c15
7 changed files with 289 additions and 158 deletions

View File

@ -85,7 +85,7 @@ venv 创建与 pip 升级 + 完整 7b 段」后 `bash` 执行、`tee` 捕获得
两处如实说明(均属本任务范围外的既有问题,已作为发现项上报 PM,未在本任务内改动): 两处如实说明(均属本任务范围外的既有问题,已作为发现项上报 PM,未在本任务内改动):
1. 截取时**跳过**了第 7 步的 `pip install -r requirements.txt`:该步指向机构内网 PyPI 源 1. 截取时**跳过**了第 7 步的 `pip install -r requirements.txt`:该步指向机构内网 PyPI 源
`http://10.20.30.41:8081`,本工作空间不可达(`curl` rc=124),会先于 7b 失败退出。 `http://DEAD-INTERNAL-PYPI-REMOVED:8081`,本工作空间不可达(`curl` rc=124),会先于 7b 失败退出。
因此场景 1 日志证明的是「7b 段自身可执行并让 pytest 在应用 venv 内可用」, 因此场景 1 日志证明的是「7b 段自身可执行并让 pytest 在应用 venv 内可用」,
**不代表**运行时依赖(ahserver/sqlor/…)在本机已装齐,也不代表 `build.sh` 全流程在本机跑通。 **不代表**运行时依赖(ahserver/sqlor/…)在本机已装齐,也不代表 `build.sh` 全流程在本机跑通。
2. 首轮曾按「截取第 1 行起的全部前缀」执行,`build.sh` 第 4 步 DDL 方言门禁 die: 2. 首轮曾按「截取第 1 行起的全部前缀」执行,`build.sh` 第 4 步 DDL 方言门禁 die:

View File

@ -18,6 +18,7 @@
* webapp(init) 置于 __main__ 守卫内,被 import 不触发启动 * webapp(init) 置于 __main__ 守卫内,被 import 不触发启动
""" """
import asyncio
import json import json
import os import os
@ -108,7 +109,11 @@ def init():
# ---- 引用模块(只读挂载,不改基表) # ---- 引用模块(只读挂载,不改基表)
load_world() load_world()
load_scene() load_scene()
load_entity() # [r13-start3 修复] entity.init.load_entity 是 async def:同步 init() 裸调只产生
# RuntimeWarning: coroutine never awaited,契约一个都不会挂上。init() 本身是同步
# 签名(ahserver webapp 以无参同步调用),无法 await;协程体是纯 ServerEnv 属性
# 赋值(无 DB/网络),asyncio.run 在临时 loop 里真跑一遍即完成注册,安全幂等。
asyncio.run(load_entity())
load_script_engine() load_script_engine()
load_scense_runtime() load_scense_runtime()
load_scense() load_scense()

194
build.sh Normal file → Executable file
View File

@ -2,7 +2,8 @@
# ===================================================================== # =====================================================================
# PBL Agent OS 验证原型(Phase 0/1)—— 一键部署脚本(test 环境) # PBL Agent OS 验证原型(Phase 0/1)—— 一键部署脚本(test 环境)
# 端口唯一事实源:projects/pbls/env/test.json(9380);本脚本不硬编码端口,从 env 读取 # 端口唯一事实源:projects/pbls/env/test.json(9380);本脚本不硬编码端口,从 env 读取
# 门禁:建表 37 张 + 9 项种子全绿 + self_check PASS 74/74 + py_compile 全通过 # 门禁:手写 DDL 32 张 + 模块 models 链 6 张(10a 步)+ 9 项种子全绿
# + self_check PASS 74/74 + py_compile 全通过
# ===================================================================== # =====================================================================
set -euo pipefail set -euo pipefail
@ -108,7 +109,8 @@ log "硬门禁 6.2 通过: ServerEnv()(L${ENV_LINE}) < load_rbac()(L${RBAC_LINE}
# 表数不可信(且旧断言 36 与 DDL 实际 37 张不符,新增 pbl_compile_task 后未同步)。 # 表数不可信(且旧断言 36 与 DDL 实际 37 张不符,新增 pbl_compile_task 后未同步)。
# 修复:改为「先剥 SQL 注释 + 字符串字面量(含 COMMENT 串),再在纯 DDL 正文上扫描」, # 修复:改为「先剥 SQL 注释 + 字符串字面量(含 COMMENT 串),再在纯 DDL 正文上扫描」,
# 由 scripts/ddl_gate_check.py 单遍字符扫描实现(非逐行正则,串内 -- / # 不会误判为注释), # 由 scripts/ddl_gate_check.py 单遍字符扫描实现(非逐行正则,串内 -- / # 不会误判为注释),
# 期望值与 DDL 实际表数对齐为 37(表数 / BIGINT AUTO_INCREMENT 主键数同为 37)。 # 期望值与 DDL 实际表数对齐(表数 / BIGINT AUTO_INCREMENT 主键数同值);
# [2026-09-28 方案 A] governance/template 6 表迁出后为 32。
[ -f "${DDL_FILE}" ] || die "缺少 DDL ${DDL_FILE}" [ -f "${DDL_FILE}" ] || die "缺少 DDL ${DDL_FILE}"
DDL_GATE="${APP_DIR}/scripts/ddl_gate_check.py" DDL_GATE="${APP_DIR}/scripts/ddl_gate_check.py"
[ -f "${DDL_GATE}" ] || die "缺少 DDL 门禁扫描器 ${DDL_GATE}(6.4 依赖,禁止回退到原文 grep)" [ -f "${DDL_GATE}" ] || die "缺少 DDL 门禁扫描器 ${DDL_GATE}(6.4 依赖,禁止回退到原文 grep)"
@ -118,11 +120,14 @@ SELFTEST_RC=0
python3 "${DDL_GATE}" --selftest || SELFTEST_RC=$? python3 "${DDL_GATE}" --selftest || SELFTEST_RC=$?
[ "${SELFTEST_RC}" = "0" ] || die "DDL 门禁扫描器自检未通过(--selftest rc=${SELFTEST_RC}),扫描结果不可信,终止部署" [ "${SELFTEST_RC}" = "0" ] || die "DDL 门禁扫描器自检未通过(--selftest rc=${SELFTEST_RC}),扫描结果不可信,终止部署"
# 4b. 在降噪后的纯 DDL 正文上核验:方言零命中 + 表数 37 + BIGINT AUTO_INCREMENT 37 # 4b. 在降噪后的纯 DDL 正文上核验:方言零命中 + 表数 32 + BIGINT AUTO_INCREMENT 32
# [2026-09-28 方案 A] pbl_governance 4 表 + pbl_template 2 表迁出至模块 models/*.json
# 单一真源(id=str(32) 应用层 gen_pk,M5a-DISC.1 裁决(a)),手写 DDL 37→32。
# 模块 6 表由第 10a 步 gen/apply 链建,不进本门禁的 autoinc 口径。
GATE_RC=0 GATE_RC=0
python3 "${DDL_GATE}" --ddl "${DDL_FILE}" --expect-tables 37 --expect-autoinc 37 || GATE_RC=$? python3 "${DDL_GATE}" --ddl "${DDL_FILE}" --expect-tables 32 --expect-autoinc 32 || GATE_RC=$?
[ "${GATE_RC}" = "0" ] || die "硬门禁 6.4 未通过(ddl_gate_check.py rc=${GATE_RC}):非 mariadb 方言关键字命中 / 表数≠37 / BIGINT AUTO_INCREMENT≠37,详见上方回显" [ "${GATE_RC}" = "0" ] || die "硬门禁 6.4 未通过(ddl_gate_check.py rc=${GATE_RC}):非 mariadb 方言关键字命中 / 表数≠32 / BIGINT AUTO_INCREMENT≠32,详见上方回显"
log "硬门禁 6.4 通过: mariadb 方言(剥注释+COMMENT 串后零命中),37 表 / 37 处 BIGINT AUTO_INCREMENT" log "硬门禁 6.4 通过: mariadb 方言(剥注释+COMMENT 串后零命中),32 表 / 32 处 BIGINT AUTO_INCREMENT"
# ---- 5. 依赖声明核验(6.5)---- # ---- 5. 依赖声明核验(6.5)----
for dep in ahserver sqlor apppublic appbase rbac PyMySQL; do for dep in ahserver sqlor apppublic appbase rbac PyMySQL; do
@ -168,7 +173,7 @@ fi
"${PY}" -m pip install --upgrade pip wheel setuptools "${PY}" -m pip install --upgrade pip wheel setuptools
# [deploy_test R5 修复 #1] 平台基础包(ahserver/sqlor/apppublic/appbase/rbac/bricks_for_python/accounting) # [deploy_test R5 修复 #1] 平台基础包(ahserver/sqlor/apppublic/appbase/rbac/bricks_for_python/accounting)
# 原走内网私有源 http://10.20.30.41:8081 —— 目标机 192.168.16.3 实测 connect timeout(5 次重试全失败), # 原走内网私有源 http://DEAD-INTERNAL-PYPI-REMOVED:8081 —— 目标机 192.168.16.3 实测 connect timeout(5 次重试全失败),
# build.sh 在此处 BUILD_RC=1 终止(R4 部署失败根因,见 evidence/r4_build.log)。 # build.sh 在此处 BUILD_RC=1 终止(R4 部署失败根因,见 evidence/r4_build.log)。
# 改为:机构 wheelhouse 离线供给(--find-links,绝对路径,不依赖 cwd)+ 公网 pypi.org 取第三方包 # 改为:机构 wheelhouse 离线供给(--find-links,绝对路径,不依赖 cwd)+ 公网 pypi.org 取第三方包
# (目标机实测 https://pypi.org/simple/pymysql/ HTTP=200)。 # (目标机实测 https://pypi.org/simple/pymysql/ HTTP=200)。
@ -336,11 +341,35 @@ log "py_compile 全部通过"
log "执行 DDL: ${DDL_FILE}" log "执行 DDL: ${DDL_FILE}"
mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \ mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
--default-character-set=utf8mb4 "${DB_NAME}" < "${DDL_FILE}" --default-character-set=utf8mb4 "${DB_NAME}" < "${DDL_FILE}"
# [2026-09-28 方案 A] 计数口径从 LIKE 'pbl_%' 改为 IN 点名 32 张手写表——
# LIKE 会被其它模块自建表(rtx pbl_entity_state 等)/迁移备份表干扰而虚高判死。
HAND_TABLES="'pbl_blueprint','pbl_blueprint_version','pbl_blueprint_lock','pbl_subobject','pbl_subobject_attr','pbl_subobject_rel','pbl_subobject_asset','pbl_subobject_ref','pbl_subobject_review','pbl_blueprint_tag','pbl_blueprint_publish','pbl_validation_ruleset','pbl_validation_run','pbl_validation_finding','pbl_compile_job','pbl_compile_artifact','pbl_game_definition','pbl_compile_log','pbl_agent_session','pbl_agent_message','pbl_agent_tool_call','pbl_agent_arbitration','pbl_artifact','pbl_evidence','pbl_evidence_idem','pbl_evidence_binding','pbl_rubric','pbl_rubric_criterion','pbl_assessment_report','pbl_domain_binding','pbl_analytics_snapshot','pbl_compile_task'"
ACTUAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \ ACTUAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name LIKE 'pbl\\_%'" "${DB_NAME}")" -N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name IN (${HAND_TABLES})" "${DB_NAME}")"
# Q4 修复:与 DDL 实际表数对齐(36 → 37,新增 pbl_compile_task) # Q4 修复:与 DDL 实际表数对齐;[2026-09-28 方案 A] governance/template 6 表迁出
[ "${ACTUAL_TABLES}" = "37" ] || die "建表数应为 37,实际 ${ACTUAL_TABLES}" # 至模块 models 链(第 10a 步建),本文件手写 DDL 37→32。
log "建表完成: pbl_* 共 ${ACTUAL_TABLES} 张" [ "${ACTUAL_TABLES}" = "32" ] || die "建表数应为 32(手写 DDL 段),实际 ${ACTUAL_TABLES}"
log "手写 DDL 建表完成: pbl_* 共 ${ACTUAL_TABLES} 张(模块 models 链 6 张见 10a 步)"
# ---- 10a. 模块 models 链建表(owner 裁决 2026-09-28 方案 A)----
# pbl_governance 4 表 + pbl_template 2 表:models/*.json 四段式 → json2ddl 生成
# → apply_ddl.py 执行(存在性门禁,绝不 DROP)。先 --check 校验 sql/ 产物与
# models 指纹一致(改表忘重跑 gen 即 die),再逐模块 apply + verify。
for MOD in pbl_governance pbl_template; do
MOD_DIR="${WS_DIR}/modules/${MOD}"
[ -f "${MOD_DIR}/scripts/gen_ddl_from_models.py" ] || die "${MOD} 缺 gen_ddl_from_models.py(models 链不完整)"
log "[10a] ${MOD}: gen --check(models↔sql 指纹幂等门禁)"
"${PY}" "${MOD_DIR}/scripts/gen_ddl_from_models.py" --check \
|| die "${MOD}: sql/ 产物与 models/*.json 指纹不一致——改表结构后必须重跑 gen_ddl_from_models.py"
log "[10a] ${MOD}: apply_ddl(存在性门禁建表 + information_schema 列核验)"
"${PY}" "${MOD_DIR}/scripts/apply_ddl.py" --verify \
|| die "${MOD}: apply_ddl 失败(详见上方真实错误链)"
done
# 模块链 6 表终态断言(含补建 pbl_privacy_consent)
MOD_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name IN ('pbl_tenant','pbl_role_binding','pbl_audit_log','pbl_privacy_consent','pbl_template','pbl_template_item')" "${DB_NAME}")"
[ "${MOD_TABLES}" = "6" ] || die "模块 models 链建表应为 6 张,实际 ${MOD_TABLES}"
log "[10a] 模块 models 链建表完成: 6/6(governance 4 含 privacy_consent + template 2)"
# ---- 10b. 运行目录前置创建(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)---- # ---- 10b. 运行目录前置创建(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)----
# 原脚本到步骤 13 才 mkdir logs/run,而步骤 11 已经 `tee ${APP_DIR}/logs/self_check.log`。 # 原脚本到步骤 13 才 mkdir logs/run,而步骤 11 已经 `tee ${APP_DIR}/logs/self_check.log`。
@ -371,17 +400,71 @@ log "枚举数量契约自检通过: 74 项 / 自检全绿(无 FAIL)"
# ---- 12. 9 项种子数据幂等注入(重复执行零差异)---- # ---- 12. 9 项种子数据幂等注入(重复执行零差异)----
log "种子注入(第 1 轮)" log "种子注入(第 1 轮)"
# [2026-09-29 修复] 种子段是**独立 python 进程**(heredoc),必须自己做 DB bootstrap:
# ServerEnv.get_module_dbname(库名唯一事实源=conf/config.json 的 module_dbname 段)
# + DBPools(cfg.databases)。原脚本从不做这一步,于是 get_db() 解析不到库名 →
# get_module_dbname fail-closed 抛 'NoneType' object is not callable,
# _table_exists 退化成误报「共享编码表 appcodes 不存在(appbase 未初始化)」,
# 最终 DbError 中断部署。此前该缺陷被 seed.py 的 `SHOW TABLES LIKE %s` 旧式传参
# (sqlor v2 只认 ${name}$ 具名 dict)以更早的 PBL_E_PARAM/AttributeError 形式
# 掩盖,两处必须一起修才能真正走通。
# bootstrap 序列与 modules/pbl_*/scripts/apply_ddl.py 及 app/pbls.py init() 同口径
# (本修复已在测试机实测:种子注入两轮幂等零差异通过)。
export SEED_APP_DIR="${APP_DIR}"
export SEED_WS_DIR="${WS_DIR}"
"${PY}" - <<'PYEOF' "${PY}" - <<'PYEOF'
import json, asyncio import json, asyncio, os, sys
# ---- DB bootstrap(独立进程必做,见上方注释)----
_APP_DIR = os.environ.get('SEED_APP_DIR') or os.getcwd()
_WS_DIR = os.environ.get('SEED_WS_DIR') or os.path.normpath(
os.path.join(_APP_DIR, '..', '..'))
os.chdir(_APP_DIR) # conf/config.json 相对路径口径
for _p in (os.path.join(_WS_DIR, 'modules', 'pbl_common'),
os.path.join(_WS_DIR, 'modules', 'pbl_appcodes'),
os.path.join(_WS_DIR, 'pkgs'),
'/d/pipeline/binpkg_overlay'):
if os.path.isdir(_p) and _p not in sys.path:
sys.path.insert(0, _p)
from ahserver.globalEnv import getConfig, DBPools
from ahserver.serverenv import ServerEnv
with open(os.path.join(_APP_DIR, 'conf', 'config.json'), encoding='utf-8') as _fh:
_MODULE_DBNAME = json.load(_fh).get('module_dbname') or {}
def _gmd(_m):
_n = _MODULE_DBNAME.get(_m) or _MODULE_DBNAME.get('default')
if not _n:
raise RuntimeError('conf/config.json 缺 module_dbname 映射: %r' % _m)
return _n
_cfg = getConfig()
_env = ServerEnv() # MUST be before DBPools(同 pbls.py init)
_env.get_module_dbname = _gmd
DBPools(_cfg.databases)
print('[seed] DB bootstrap 完成: 目标库=%s(module_dbname 映射,非硬编码)'
% _gmd('pbl_appcodes'))
from pbl_appcodes.seed import seed_all from pbl_appcodes.seed import seed_all
# [deploy_test R12 定点补丁] seed_all 是 async def(modules/pbl_appcodes/pbl_appcodes/seed.py:460), # [deploy_test R12 定点补丁] seed_all 是 async def,原脚本同步调用 → TypeError:
# 原脚本同步调用 → TypeError: Object of type coroutine is not JSON serializable # Object of type coroutine is not JSON serializable + RuntimeWarning: never awaited。
# + RuntimeWarning: coroutine 'seed_all' was never awaited(r12d 实测,建表 37/37 后种子段中断)。 # [2026-09-29 修复] 两轮必须跑在**同一个事件循环**里,不能各自 asyncio.run():
# 修法:asyncio.run 驱动协程,两轮各自独立事件循环(幂等对比口径不变)。 # sqlor 连接池的 Future 绑定首次建池的 loop,第二次 asyncio.run() 新建 loop 后
s1 = asyncio.run(seed_all()) # 复用同一连接池 → "got Future attached to a different loop"(build_run7 实测,
print('[seed round1]', json.dumps(s1, ensure_ascii=False, sort_keys=True)) # 第 1 轮 inserted=82 成功、第 2 轮崩)。幂等对比口径不变:同 loop 内顺序两轮,
s2 = asyncio.run(seed_all()) # 第 2 轮仍须零插入零更新。
print('[seed round2]', json.dumps(s2, ensure_ascii=False, sort_keys=True)) async def _seed_two_rounds():
r1 = await seed_all()
print('[seed round1]', json.dumps(r1, ensure_ascii=False, sort_keys=True))
r2 = await seed_all()
print('[seed round2]', json.dumps(r2, ensure_ascii=False, sort_keys=True))
return r1, r2
s1, s2 = asyncio.run(_seed_two_rounds())
# [deploy_test R12 定点补丁 #2] 原断言用 groups_inserted/items_inserted/idempotent 三个键, # [deploy_test R12 定点补丁 #2] 原断言用 groups_inserted/items_inserted/idempotent 三个键,
# 但 seed_all 真实返回契约(seed.py:494-499)是 ok/groups/items/inserted/updated/skipped/per_group, # 但 seed_all 真实返回契约(seed.py:494-499)是 ok/groups/items/inserted/updated/skipped/per_group,
# 无上述键 → 同步调用修好后必然 KeyError。改按真实返回键断言,幂等口径不变(第二轮零插入零更新)。 # 无上述键 → 同步调用修好后必然 KeyError。改按真实返回键断言,幂等口径不变(第二轮零插入零更新)。
@ -398,6 +481,36 @@ PYEOF
# 先跑 --check 做通配符 + 聚合完整性门禁(rc!=0 即终止,禁止带缺口部署)。 # 先跑 --check 做通配符 + 聚合完整性门禁(rc!=0 即终止,禁止带缺口部署)。
LOAD_PATH="${APP_DIR}/scripts/load_path.py" LOAD_PATH="${APP_DIR}/scripts/load_path.py"
[ -f "${LOAD_PATH}" ] || die "缺少 RBAC 注册入口 ${LOAD_PATH}" [ -f "${LOAD_PATH}" ] || die "缺少 RBAC 注册入口 ${LOAD_PATH}"
# ---- 12.45 RBAC 写入工具定位(set_role_perm.py)----
# [2026-09-29 修复] load_path.py 的 find_set_perm() 候选根是 ~/repos/sage、
# ~/test/sage、/d/ymq/repos/sage、/opt/sage 四个**外部 sage 根**加 PATH;本机
# (pipeline-app 部署形态)一个都不命中(/d/ymq/repos/sage 存在但对 pipeline
# 账号 Permission denied,os.path.exists 返回 False 故不会误选),于是
# register() 把 308 条路径全部记为 PENDING 并返回 rc=1 → 12.5 die。
# 本机真实副本是 /d/pipeline/pipeline-app/set_role_perm.py(全机唯一 find 命中,
# conf/config.json 完备)。该工具实测:
# · 可写库:`Registered: logined(logined) -> /pipeline-pbls/healthz_probe.ui`,rc=0
# (退出时 aiomysql 的 "Event loop is closed" 是 GC 噪音,不影响写入)
# · 先查后写天然幂等:连跑 3 次 permission / rolepermission 各仍只 1 行
# · 单次约 0.65s → 308 条约 200s,在本脚本 timeout 预算内
# 修法用 load_path.py **原生支持的 RBAC_SET_PERM 覆盖**,不改 load_path.py
# (该文件属上游 agent.develop,其候选口径面向 sage 根,改它会波及其他宿主)。
if [ -n "${RBAC_SET_PERM:-}" ] && [ -f "${RBAC_SET_PERM}" ]; then
log "RBAC 写入工具: ${RBAC_SET_PERM}(外部 RBAC_SET_PERM 指定)"
else
RBAC_SET_PERM=""
for _cand in /d/pipeline/pipeline-app/set_role_perm.py \
"${PIPE_APP_DIR:-/nonexistent}/set_role_perm.py" \
"$(command -v set_role_perm.py || true)"; do
if [ -n "${_cand}" ] && [ -f "${_cand}" ]; then RBAC_SET_PERM="${_cand}"; break; fi
done
if [ -z "${RBAC_SET_PERM}" ]; then
die "未找到 RBAC 写入工具 set_role_perm.py:load_path.py 候选根(~/repos/sage、~/test/sage、/d/ymq/repos/sage、/opt/sage)本机均不存在或不可读。处置:导出 RBAC_SET_PERM=<绝对路径> 后重跑(本机参考位置 /d/pipeline/pipeline-app/set_role_perm.py)"
fi
log "RBAC 写入工具: ${RBAC_SET_PERM}(本机候选探测)"
fi
export RBAC_SET_PERM
log "RBAC 一致性门禁: load_path.py --check" log "RBAC 一致性门禁: load_path.py --check"
CHK_RBAC=0 CHK_RBAC=0
WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" --check || CHK_RBAC=$? WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" --check || CHK_RBAC=$?
@ -433,10 +546,13 @@ else
# 可能未装 rsync,原实现直接调用 → command not found → set -e 下 die, # 可能未装 rsync,原实现直接调用 → command not found → set -e 下 die,
# 部署目录 /d/pbl/pbls 永远建不出来(复测门禁 ④ 恒红)。 # 部署目录 /d/pbl/pbls 永远建不出来(复测门禁 ④ 恒红)。
# 无 rsync 时退化为 tar 管道复制(POSIX 必备,保留权限与软链本身),并显式清理运行态目录。 # 无 rsync 时退化为 tar 管道复制(POSIX 必备,保留权限与软链本身),并显式清理运行态目录。
# [Bug r13-start2 修复·续] exclude 模式必须无尾斜杠:'venv/' 只匹配目录,venv 是软链时
# 不命中 → --delete 会把部署侧 venv 软链删掉(实测发生)。并排除 venv.stale-* 保留目录。
if command -v rsync >/dev/null 2>&1; then if command -v rsync >/dev/null 2>&1; then
rsync -a --delete \ rsync -a --delete \
--exclude 'venv/' --exclude '.venv/' --exclude 'logs/' --exclude 'run/' \ --exclude 'venv' --exclude '.venv' --exclude 'venv.stale-*' --exclude '.venv.stale-*' \
--exclude '.git/' --exclude '__pycache__/' --exclude '*.pyc' \ --exclude 'logs' --exclude 'run' \
--exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \
"${APP_DIR}/" "${DEPLOY_PATH}/" "${APP_DIR}/" "${DEPLOY_PATH}/"
else else
log "rsync 不可用,退化 tar 管道同步(保留权限/软链)" log "rsync 不可用,退化 tar 管道同步(保留权限/软链)"
@ -449,8 +565,21 @@ else
fi fi
mkdir -p "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run" mkdir -p "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run"
# venv 软链复用(目标侧 start_cmd 要求 ${DEPLOY_PATH}/venv/bin/python 可用) # venv 软链复用(目标侧 start_cmd 要求 ${DEPLOY_PATH}/venv/bin/python 可用)
if [ ! -e "${DEPLOY_PATH}/venv" ]; then # [Bug r13-start2 修复] 原条件 [ ! -e venv ] 对部署目录里**真实存在的陈旧 venv 目录**
# 恒为假 → 软链永远建不出来 → 启动实际用旧 venv 的旧 rbac(无 event_dispatcher 判空守卫),
# load_rbac 即 AttributeError: NoneType.bind,进程数=0/healthz=000。
# 处置:venv 是目录(非软链)或软链目标不符时,同目录 rename 保留为 venv.stale-<ts>
# 再建软链——不跨文件系统 mv(会退化 copy+delete 失败),不 rm(保留证据可回退)。
if [ -L "${DEPLOY_PATH}/venv" ] && [ "$(readlink -f "${DEPLOY_PATH}/venv")" = "$(readlink -f "${VENV_DIR}")" ]; then
log "部署目录 venv 软链已指向 ${VENV_DIR},跳过"
else
if [ -e "${DEPLOY_PATH}/venv" ] || [ -L "${DEPLOY_PATH}/venv" ]; then
STALE="${DEPLOY_PATH}/venv.stale-$(date +%Y%m%d%H%M%S)"
mv "${DEPLOY_PATH}/venv" "${STALE}" || die "陈旧 venv 无法移开: ${DEPLOY_PATH}/venv -> ${STALE}"
log "陈旧 venv 已保留为 ${STALE}(属主 $(stat -c %U:%G "${STALE}" 2>/dev/null || echo unknown))"
fi
ln -sfn "${VENV_DIR}" "${DEPLOY_PATH}/venv" ln -sfn "${VENV_DIR}" "${DEPLOY_PATH}/venv"
log "venv 软链建立: ${DEPLOY_PATH}/venv -> ${VENV_DIR}"
fi fi
[ -x "${DEPLOY_PATH}/venv/bin/python" ] || die "部署目录 venv 不可用: ${DEPLOY_PATH}/venv/bin/python" [ -x "${DEPLOY_PATH}/venv/bin/python" ] || die "部署目录 venv 不可用: ${DEPLOY_PATH}/venv/bin/python"
[ -f "${DEPLOY_PATH}/app/${APP_NAME}.py" ] || die "部署目录缺入口: ${DEPLOY_PATH}/app/${APP_NAME}.py" [ -f "${DEPLOY_PATH}/app/${APP_NAME}.py" ] || die "部署目录缺入口: ${DEPLOY_PATH}/app/${APP_NAME}.py"
@ -507,10 +636,21 @@ if [ "${HZ_CODE}" != "200" ] || [ "${AH_CODE}" != "200" ]; then
die "健康检查失败(/healthz=${HZ_CODE}, /api/public/health=${AH_CODE}),门禁 ① 不成立" die "健康检查失败(/healthz=${HZ_CODE}, /api/public/health=${AH_CODE}),门禁 ① 不成立"
fi fi
# 表数终态复述(对应复测门禁 ③):部署完成后 pbl 库必须有 37 张 pbl_* 表。 # 表数终态复述(对应复测门禁 ③):部署完成后 pbl 库必须**逐张在场** 38 张关键表
# (手写 DDL 32 + 模块 models 链 6:governance 4 表含补建 pbl_privacy_consent + template 2 表)。
# [2026-09-28 方案 A] 断言从「LIKE 计数」升级为「关键表逐张点名」——裸计数会被
# 其它模块启动期自建表/迁移备份表干扰(计数虚高同样判死),点名在场才是确定性门禁。
MOD6_TABLES="'pbl_tenant','pbl_role_binding','pbl_audit_log','pbl_privacy_consent','pbl_template','pbl_template_item'"
REQUIRED_TABLES="${MOD6_TABLES},${HAND_TABLES}"
FINAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \ FINAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name IN (${REQUIRED_TABLES})" "${DB_NAME}")"
TOTAL_PBL="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name LIKE 'pbl\\_%'" "${DB_NAME}")" -N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name LIKE 'pbl\\_%'" "${DB_NAME}")"
log "终态表数: ${DB_NAME}.pbl_* = ${FINAL_TABLES}/37" log "终态表数: 关键表 ${FINAL_TABLES}/38 在场(pbl_* 总计 ${TOTAL_PBL} 张,含模块自建/扩展表)"
[ "${FINAL_TABLES}" = "37" ] || die "终态表数 ${FINAL_TABLES} != 37,门禁 ③ 不成立" [ "${FINAL_TABLES}" = "38" ] || {
MISSING="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \
-N -B -e "SELECT GROUP_CONCAT(t.n ORDER BY t.n) FROM (SELECT 'pbl_tenant' AS n UNION ALL SELECT 'pbl_role_binding' AS n UNION ALL SELECT 'pbl_audit_log' AS n UNION ALL SELECT 'pbl_privacy_consent' AS n UNION ALL SELECT 'pbl_template' AS n UNION ALL SELECT 'pbl_template_item' AS n UNION ALL SELECT 'pbl_blueprint' AS n UNION ALL SELECT 'pbl_blueprint_version' AS n UNION ALL SELECT 'pbl_blueprint_lock' AS n UNION ALL SELECT 'pbl_subobject' AS n UNION ALL SELECT 'pbl_subobject_attr' AS n UNION ALL SELECT 'pbl_subobject_rel' AS n UNION ALL SELECT 'pbl_subobject_asset' AS n UNION ALL SELECT 'pbl_subobject_ref' AS n UNION ALL SELECT 'pbl_subobject_review' AS n UNION ALL SELECT 'pbl_blueprint_tag' AS n UNION ALL SELECT 'pbl_blueprint_publish' AS n UNION ALL SELECT 'pbl_validation_ruleset' AS n UNION ALL SELECT 'pbl_validation_run' AS n UNION ALL SELECT 'pbl_validation_finding' AS n UNION ALL SELECT 'pbl_compile_job' AS n UNION ALL SELECT 'pbl_compile_artifact' AS n UNION ALL SELECT 'pbl_game_definition' AS n UNION ALL SELECT 'pbl_compile_log' AS n UNION ALL SELECT 'pbl_agent_session' AS n UNION ALL SELECT 'pbl_agent_message' AS n UNION ALL SELECT 'pbl_agent_tool_call' AS n UNION ALL SELECT 'pbl_agent_arbitration' AS n UNION ALL SELECT 'pbl_artifact' AS n UNION ALL SELECT 'pbl_evidence' AS n UNION ALL SELECT 'pbl_evidence_idem' AS n UNION ALL SELECT 'pbl_evidence_binding' AS n UNION ALL SELECT 'pbl_rubric' AS n UNION ALL SELECT 'pbl_rubric_criterion' AS n UNION ALL SELECT 'pbl_assessment_report' AS n UNION ALL SELECT 'pbl_domain_binding' AS n UNION ALL SELECT 'pbl_analytics_snapshot' AS n UNION ALL SELECT 'pbl_compile_task' AS n) t WHERE NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name=t.n)" "${DB_NAME}" 2>/dev/null)"
die "关键表在场 ${FINAL_TABLES} != 38(32 手写 + 6 模块 models 链),门禁 ③ 不成立——缺表: ${MISSING:-未知(mysql查询失败)}"
}
log "BUILD SUCCESS: ${APP_NAME} 已运行于 http://127.0.0.1:${PORT}/ (部署路径 ${DEPLOY_PATH},/healthz=200,表数 ${FINAL_TABLES}/37)" log "BUILD SUCCESS: ${APP_NAME} 已运行于 http://127.0.0.1:${PORT}/ (部署路径 ${DEPLOY_PATH},/healthz=200,关键表 ${FINAL_TABLES}/38)"

View File

@ -29,7 +29,7 @@
"port": 3306, "port": 3306,
"user": "test", "user": "test",
"password": "hImoFMkBVbdGiQE030YGvg==", "password": "hImoFMkBVbdGiQE030YGvg==",
"database": "pbl", "db": "pbl",
"charset": "utf8mb4" "charset": "utf8mb4"
} }
} }
@ -51,7 +51,14 @@
} }
}, },
"website": { "website": {
"port": 9380,
"root": "wwwroot", "root": "wwwroot",
"paths": [
[
"$[workdir]$/wwwroot",
""
]
],
"indexes": [ "indexes": [
"index.ui", "index.ui",
"index.tmpl", "index.tmpl",

View File

@ -1,7 +1,7 @@
# PBL Agent OS 验证原型(Phase 0/1)—— 应用依赖声明 # PBL Agent OS 验证原型(Phase 0/1)—— 应用依赖声明
# 平台基础依赖(ahserver / sqlor / apppublic / appbase / rbac)由机构私有源提供, # 平台基础依赖(ahserver / sqlor / apppublic / appbase / rbac)由机构私有源提供,
# 通过 --index-url 指向内网 pypi;缺失时 build.sh 会 fail-fast 报错而非静默跳过。 # 通过 --index-url 指向内网 pypi;缺失时 build.sh 会 fail-fast 报错而非静默跳过。
# [deploy_test R5 修复] 原 --extra-index-url http://10.20.30.41:8081/... 在目标机 192.168.16.3 # [deploy_test R5 修复] 原 --extra-index-url http://DEAD-INTERNAL-PYPI-REMOVED:8081/... 在目标机 192.168.16.3
# 实测 connect timeout(5 次重试全失败 → build.sh BUILD_RC=1,部署卡在依赖安装)。 # 实测 connect timeout(5 次重试全失败 → build.sh BUILD_RC=1,部署卡在依赖安装)。
# 平台基础包改由机构 wheelhouse 离线供给(--find-links 相对本文件定位 WS/wheelhouse), # 平台基础包改由机构 wheelhouse 离线供给(--find-links 相对本文件定位 WS/wheelhouse),
# 第三方包走公网 pypi.org(目标机实测 HTTP=200 可达)。 # 第三方包走公网 pypi.org(目标机实测 HTTP=200 可达)。
@ -13,7 +13,7 @@
# 已由 build.sh 硬门禁 6.6(scripts/check_requirements_satisfiable.py)静态把关: # 已由 build.sh 硬门禁 6.6(scripts/check_requirements_satisfiable.py)静态把关:
# 幻影包黑名单(pybricks / mysqlclient / hashlib-additional / 本地 pbl_* 模块)、 # 幻影包黑名单(pybricks / mysqlclient / hashlib-additional / 本地 pbl_* 模块)、
# 标准库名误当第三方声明、基座包 == 版本与 wheelhouse 实物逐字一致、 # 标准库名误当第三方声明、基座包 == 版本与 wheelhouse 实物逐字一致、
# wheelhouse 覆盖完整性、门禁 6.5 六项声明在位、失效内网源 10.20.30.41 残留、 # wheelhouse 覆盖完整性、门禁 6.5 六项声明在位、失效内网源 DEAD-INTERNAL-PYPI-REMOVED 残留、
# 相对路径 --find-links(pip 不解析)——并同步扫描全部 modules/*/pyproject.toml 同类缺陷。 # 相对路径 --find-links(pip 不解析)——并同步扫描全部 modules/*/pyproject.toml 同类缺陷。
# 基座版本口径 = env/test.json deps.corrected_requirements_hint(业主 2026-09-25 修正)。 # 基座版本口径 = env/test.json deps.corrected_requirements_hint(业主 2026-09-25 修正)。

View File

@ -1,7 +1,11 @@
-- ===================================================================== -- =====================================================================
-- PBL Agent OS 验证原型(Phase 0/1)—— 36 表可执行 DDL -- PBL Agent OS 验证原型(Phase 0/1)—— 32 表可执行 DDL
-- 【2026-09-28 方案 A】pbl_governance 4 表 + pbl_template 2 表已迁出至各模块
-- models/*.json 单一真源(json2ddl 生成 + apply_ddl 执行),本文件余 32 表。
-- 方言:mariadb(InnoDB + utf8mb4/utf8mb4_general_ci) -- 方言:mariadb(InnoDB + utf8mb4/utf8mb4_general_ci)
-- 规则:id BIGINT NOT NULL AUTO_INCREMENT 物理主键 + xxx_code VARCHAR(32) 业务主键(唯一索引) -- 规则:id BIGINT NOT NULL AUTO_INCREMENT 物理主键 + xxx_code VARCHAR(32) 业务主键(唯一索引)
-- (⚠ 仅限本文件遗留 32 表;已迁出模块表一律 id=str(32) 应用层 gen_pk,
-- 见各模块 models/*.json —— M5a-DISC.1 裁决(a),本文件为存量过渡真源)
-- tenant_id VARCHAR(32) NOT NULL 为第一业务列,所有索引以 tenant_id 打头 -- tenant_id VARCHAR(32) NOT NULL 为第一业务列,所有索引以 tenant_id 打头
-- 禁止 FOREIGN KEY / 禁止 ENUM / 禁止原生 TIMESTAMP(用 DATETIME) -- 禁止 FOREIGN KEY / 禁止 ENUM / 禁止原生 TIMESTAMP(用 DATETIME)
-- append-only 表无 update_time、无物理删除(11 张,见文末清单) -- append-only 表无 update_time、无物理删除(11 张,见文末清单)
@ -13,75 +17,14 @@ SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0; SET FOREIGN_KEY_CHECKS = 0;
-- ===================================================================== -- =====================================================================
-- 模块 pbl_governance(3 表) -- 模块 pbl_governance(4 表)——【已迁出本文件】owner 裁决 2026-09-28 方案 A:
-- 表定义单一真源 = modules/pbl_governance/models/*.json(四段式,id=str(32)
-- 应用层 gen_pk,M5a-DISC.1 裁决(a)),DDL 由模块 scripts/gen_ddl_from_models.py
-- 真调平台 json2ddl 生成(sql/pbl_governance.sql)、scripts/apply_ddl.py 执行。
-- 本文件不得再维护 pbl_tenant / pbl_role_binding / pbl_audit_log /
-- pbl_privacy_consent(design §A3 补建,手写 DDL 时代缺失)任何列定义。
-- ===================================================================== -- =====================================================================
-- 1. pbl_tenant 租户主表
CREATE TABLE IF NOT EXISTS pbl_tenant (
tenant_id VARCHAR(32) NOT NULL COMMENT '租户ID(第一业务列)',
id BIGINT NOT NULL AUTO_INCREMENT,
tenant_code VARCHAR(32) NOT NULL COMMENT '租户业务主键',
tenant_name VARCHAR(128) NOT NULL COMMENT '租户名称',
org_code VARCHAR(32) DEFAULT NULL COMMENT '所属机构编码(rbac 侧只读引用)',
status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态:active/suspended/closed(appcodes_kv: pbl_tenant_status)',
quota_bp INT NOT NULL DEFAULT 100 COMMENT '蓝图配额',
quota_stu INT NOT NULL DEFAULT 500 COMMENT '学生席位配额',
remark VARCHAR(512) DEFAULT NULL COMMENT '备注',
create_by VARCHAR(32) DEFAULT NULL COMMENT '创建人',
create_time DATETIME DEFAULT NULL COMMENT '创建时间',
update_by VARCHAR(32) DEFAULT NULL COMMENT '更新人',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (id),
UNIQUE KEY uk_pbl_tenant_code (tenant_id, tenant_code),
KEY idx_pbl_tenant_tid_status (tenant_id, status),
KEY idx_pbl_tenant_tid_org (tenant_id, org_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL 租户主表';
-- 2. pbl_role_binding 租户内角色绑定(复用 rbac,不改 rbac 基表)
CREATE TABLE IF NOT EXISTS pbl_role_binding (
tenant_id VARCHAR(32) NOT NULL COMMENT '租户ID',
id BIGINT NOT NULL AUTO_INCREMENT,
binding_code VARCHAR(32) NOT NULL COMMENT '绑定业务主键',
user_code VARCHAR(32) NOT NULL COMMENT '用户编码(rbac 只读引用)',
role_code VARCHAR(32) NOT NULL COMMENT '角色编码:teacher/assistant/student/observer/admin(appcodes_kv: pbl_role)',
scope_type VARCHAR(16) NOT NULL DEFAULT 'tenant' COMMENT '作用域类型:tenant/blueprint/class/team',
scope_code VARCHAR(32) DEFAULT NULL COMMENT '作用域对象编码',
status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态:active/revoked',
effective_from DATETIME DEFAULT NULL COMMENT '生效起',
effective_to DATETIME DEFAULT NULL COMMENT '生效止',
create_by VARCHAR(32) DEFAULT NULL,
create_time DATETIME DEFAULT NULL,
update_by VARCHAR(32) DEFAULT NULL,
update_time DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_pbl_role_binding_code (tenant_id, binding_code),
KEY idx_pbl_role_binding_tid_user (tenant_id, user_code, status),
KEY idx_pbl_role_binding_tid_scope (tenant_id, scope_type, scope_code),
KEY idx_pbl_role_binding_tid_role (tenant_id, role_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL 租户角色绑定';
-- 3. pbl_audit_log 审计日志(append-only)
CREATE TABLE IF NOT EXISTS pbl_audit_log (
tenant_id VARCHAR(32) NOT NULL COMMENT '租户ID',
id BIGINT NOT NULL AUTO_INCREMENT,
audit_code VARCHAR(32) NOT NULL COMMENT '审计业务主键',
actor_type VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '主体类型:user/agent/system',
actor_code VARCHAR(32) DEFAULT NULL COMMENT '主体编码',
action_code VARCHAR(64) NOT NULL COMMENT '动作编码(appcodes_kv: pbl_audit_action)',
object_type VARCHAR(32) DEFAULT NULL COMMENT '对象类型',
object_code VARCHAR(32) DEFAULT NULL COMMENT '对象编码',
result VARCHAR(16) NOT NULL DEFAULT 'success' COMMENT '结果:success/denied/error',
detail TEXT DEFAULT NULL COMMENT '详情 JSON',
trace_id VARCHAR(64) DEFAULT NULL COMMENT '链路追踪ID',
client_ip VARCHAR(64) DEFAULT NULL COMMENT '来源IP',
create_time DATETIME DEFAULT NULL COMMENT '发生时间(append-only,无 update_time)',
PRIMARY KEY (id),
UNIQUE KEY uk_pbl_audit_code (tenant_id, audit_code),
KEY idx_pbl_audit_tid_time (tenant_id, create_time),
KEY idx_pbl_audit_tid_obj (tenant_id, object_type, object_code),
KEY idx_pbl_audit_tid_actor (tenant_id, actor_type, actor_code),
KEY idx_pbl_audit_tid_trace (tenant_id, trace_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL 审计日志(append-only)';
-- ===================================================================== -- =====================================================================
-- 模块 pbl_blueprint(11 表,M1a) -- 模块 pbl_blueprint(11 表,M1a)
@ -329,52 +272,13 @@ CREATE TABLE IF NOT EXISTS pbl_blueprint_publish (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL 蓝图发布记录(append-only)'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL 蓝图发布记录(append-only)';
-- ===================================================================== -- =====================================================================
-- 模块 pbl_template(2 表,M1b) -- 模块 pbl_template(2 表)——【已迁出本文件】owner 裁决 2026-09-28 方案 A:
-- 表定义单一真源 = modules/pbl_template/models/*.json(四段式,id=str(32)
-- 应用层 gen_pk,M5a-DISC.1 裁决(a)),DDL 由模块 scripts/gen_ddl_from_models.py
-- 真调平台 json2ddl 生成(sql/pbl_template.sql)、scripts/apply_ddl.py 执行。
-- 本文件不得再维护 pbl_template / pbl_template_item 任何列定义。
-- ===================================================================== -- =====================================================================
-- 15. pbl_template 模板主表(含离线兜底模板)
CREATE TABLE IF NOT EXISTS pbl_template (
tenant_id VARCHAR(32) NOT NULL COMMENT '租户ID(__system__ 表示平台内置)',
id BIGINT NOT NULL AUTO_INCREMENT,
tpl_code VARCHAR(32) NOT NULL COMMENT '模板业务主键',
tpl_name VARCHAR(128) NOT NULL COMMENT '模板名称',
tpl_kind VARCHAR(32) NOT NULL DEFAULT 'blueprint' COMMENT '模板种类:blueprint/subobject/rubric',
subject_code VARCHAR(32) DEFAULT NULL COMMENT '适用学科',
grade_code VARCHAR(32) DEFAULT NULL COMMENT '适用学段',
offline_flag TINYINT NOT NULL DEFAULT 0 COMMENT '是否离线兜底模板 0否1是',
body LONGTEXT DEFAULT NULL COMMENT '模板正文 JSON(含子对象树)',
id_prefix_rule VARCHAR(64) DEFAULT NULL COMMENT '实例化 ID 重映射前缀规则',
version_no INT NOT NULL DEFAULT 1 COMMENT '模板版本',
status VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态:active/disabled',
create_by VARCHAR(32) DEFAULT NULL,
create_time DATETIME DEFAULT NULL,
update_by VARCHAR(32) DEFAULT NULL,
update_time DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_pbl_template_code (tenant_id, tpl_code),
KEY idx_pbl_template_tid_kind (tenant_id, tpl_kind, status),
KEY idx_pbl_template_tid_subj (tenant_id, subject_code, grade_code),
KEY idx_pbl_template_tid_offline (tenant_id, offline_flag)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL 模板主表';
-- 16. pbl_template_item 模板条目(实例化 ID 重映射映射表)
CREATE TABLE IF NOT EXISTS pbl_template_item (
tenant_id VARCHAR(32) NOT NULL COMMENT '租户ID',
id BIGINT NOT NULL AUTO_INCREMENT,
item_code VARCHAR(32) NOT NULL COMMENT '条目业务主键',
tpl_code VARCHAR(32) NOT NULL COMMENT '模板编码',
tpl_obj_key VARCHAR(64) NOT NULL COMMENT '模板内占位键(如 __OBJ_1__)',
obj_type VARCHAR(32) NOT NULL COMMENT '子对象类型',
parent_key VARCHAR(64) DEFAULT NULL COMMENT '父占位键',
payload LONGTEXT DEFAULT NULL COMMENT '条目载荷 JSON',
sort_no INT NOT NULL DEFAULT 0 COMMENT '排序',
create_time DATETIME DEFAULT NULL,
update_time DATETIME DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_pbl_template_item_code (tenant_id, item_code),
UNIQUE KEY uk_pbl_template_item_key (tenant_id, tpl_code, tpl_obj_key),
KEY idx_pbl_template_item_tid_tpl (tenant_id, tpl_code, sort_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PBL 模板条目';
-- ===================================================================== -- =====================================================================
-- 模块 pbl_validation(3 表,M2:14 维 + 5 级质量状态) -- 模块 pbl_validation(3 表,M2:14 维 + 5 级质量状态)
@ -880,10 +784,12 @@ SET FOREIGN_KEY_CHECKS = 1;
-- ===================================================================== -- =====================================================================
-- 契约自检(供 build.sh / QC grep 核验) -- 契约自检(供 build.sh / QC grep 核验)
-- 表总数:37(pbl_governance 3 + pbl_blueprint 11 + pbl_template 2 + pbl_validation 3 -- 表总数:32(pbl_blueprint 11 + pbl_validation 3 + pbl_compiler 5
-- + pbl_compiler 5 + pbl_agent_runtime 4 + pbl_evidence 4 + pbl_assessment 3 -- + pbl_agent_runtime 4 + pbl_evidence 4 + pbl_assessment 3
-- + pbl_domain_ext 1 + pbl_analytics 1 = 37) -- + pbl_domain_ext 1 + pbl_analytics 1 = 32)
-- 注:pbl_compiler 由 4 增至 5(新增 pbl_compile_task),build.sh 门禁 6.4 表数断言同步 36→37 -- 注:pbl_governance 3 表 + pbl_template 2 表已迁出至模块 models/*.json 单一真源
-- (owner 裁决 2026-09-28 方案 A),build.sh 门禁 6.4 断言同步 37→32;
-- 模块侧另建 6 表(含补建 pbl_privacy_consent),库内 pbl_* 终态 = 32+6 = 38
-- append-only 表(11,无 update_time): -- append-only 表(11,无 update_time):
-- pbl_audit_log / pbl_blueprint_version / pbl_subobject_review / pbl_blueprint_publish -- pbl_audit_log / pbl_blueprint_version / pbl_subobject_review / pbl_blueprint_publish
-- pbl_validation_finding / pbl_compile_log / pbl_agent_message / pbl_agent_tool_call -- pbl_validation_finding / pbl_compile_log / pbl_agent_message / pbl_agent_tool_call

View File

@ -136,7 +136,49 @@ PBL_EVIDENCE_FALLBACK = [
# 授权严格度排序:数值越大越严格。冲突时保留更严格者,避免合并顺序放宽权限。 # 授权严格度排序:数值越大越严格。冲突时保留更严格者,避免合并顺序放宽权限。
ROLE_STRICTNESS = {'any': 0, 'logined': 1, 'owner.operator': 2, 'owner.superuser': 3} ROLE_STRICTNESS = {'any': 0, 'logined': 1, 'owner.operator': 2, 'owner.superuser': 3,
'admin': 2, 'anonymous': 0}
# ── 角色可解析性词汇表(复刻 set_role_perm.py 的判定,离线即可审计)────────────
# 写入工具的角色解析规则:any/anonymous/logined 是特殊角色,直接用字面 id;
# 其余按 ``[orgtypeid.]name`` 查 role 表(缺省 orgtypeid='*'),查不到即 exit(1)。
# 因此**自造角色名必然注册失败**,而这类失败原先只在 register 阶段以不透明的
# rc=1 形式出现(stderr/stdout 被 DEVNULL 吞掉),排障成本极高。
SPECIAL_ROLES = ('any', 'anonymous', 'logined')
# 已知具名角色:需在 role 表存在对应行(orgtypeid='*' 或显式带前缀)。
# 平台词汇里的既有具名角色(role 表实测存在)。模块若要用**此处未列**的具名角色,
# 必须先确认该行由种子保证存在,并把它加进本表——否则 --check 门禁会拦下。
KNOWN_NAMED_ROLES = ('admin', 'owner.superuser', 'owner.operator',
'owner.audit', 'owner.maintainer')
RESOLVABLE_ROLES = SPECIAL_ROLES + KNOWN_NAMED_ROLES
def audit_roles(paths):
"""离线审计角色名是否可被 set_role_perm.py 解析。
返回 [(path, role, reason), ...];空列表即全部可解析。
为什么必须做这道门禁:角色是运行时可增删的(role/orgtypes 有 CRUD),
模块自造角色名(如 pbl_agent_runtime 曾用 ``user``)在 role 表里没有
``orgtypeid='*'`` 行时,set_role_perm.py 直接 exit(1) → 该路径 PENDING →
端点上线即 403。2026-09-29 实测:total=308 ok=300 pending=8,8 条全是
自造 ``user``。这道审计把它提前到 --check 阶段并给出可行动的修法。
"""
bad = []
for path, role in paths:
r = str(role or '').strip()
if not r:
bad.append((path, role, '角色名为空'))
elif r not in RESOLVABLE_ROLES:
hint = ('特殊角色(字面 id,恒可解析):%s;'
'已知具名角色(需 role 表有对应行):%s。'
'若这是「普通登录用户」语义,平台既有词是 logined;'
'若确需新具名角色,须先由种子保证 role 表存在该行'
'(orgtypeid=* 或声明时带 orgtype 前缀),再加入 '
'KNOWN_NAMED_ROLES。'
% ('/'.join(SPECIAL_ROLES), '/'.join(KNOWN_NAMED_ROLES)))
bad.append((path, r, '角色名不在平台可解析词汇表内 → %s' % hint))
return bad
def _stricter(a, b): def _stricter(a, b):
@ -349,17 +391,38 @@ def register(paths, verbose=True):
'(rerun inside the app venv on the deploy host)' % len(paths)) '(rerun inside the app venv on the deploy host)' % len(paths))
return [p for p, _r in paths] return [p for p, _r in paths]
py = sys.executable or 'python3' py = sys.executable or 'python3'
# [2026-09-29 修复] 原先 stdout/stderr 全 DEVNULL,工具那句
# `ERROR: role not found: *.user` 被丢掉,只剩 PENDING 清单 —— 报错不可行动
# (用户纪律:报错必须真实可行动)。改为捕获并回显首行原因。
pending = [] pending = []
reasons = {}
for path, role in paths: for path, role in paths:
rc = subprocess.call([py, tool, role, path], proc = subprocess.run([py, tool, role, path],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
if rc != 0: text=True, timeout=120)
if proc.returncode != 0:
pending.append((path, role)) pending.append((path, role))
first = ''
for ln in (proc.stdout or '').splitlines():
ln = ln.strip()
# 过滤 aiomysql 退出噪音("Event loop is closed" 的 GC traceback)
if ln and 'Event loop is closed' not in ln and not ln.startswith(('Traceback', 'File "', 'RuntimeError')):
first = ln
break
reasons[(path, role)] = first or 'rc=%d(工具无输出)' % proc.returncode
if verbose: if verbose:
print('[apps/pbls] rbac paths: total=%d ok=%d pending=%d' print('[apps/pbls] rbac paths: total=%d ok=%d pending=%d'
% (len(paths), len(paths) - len(pending), len(pending))) % (len(paths), len(paths) - len(pending), len(pending)))
for path, role in pending: for path, role in pending:
print(' PENDING %-8s %s' % (role, path)) print(' PENDING %-8s %s' % (role, path))
why = reasons.get((path, role))
if why:
print(' 原因: %s' % why)
if pending:
print('[apps/pbls] 修法提示: 角色名须是平台可解析口径(%s 为特殊角色;'
'%s 为已知具名角色)。自造角色名会让 set_role_perm.py exit(1),'
'端点上线即 403。跑 `--check` 可在注册前离线查出全部非法角色名。'
% ('/'.join(SPECIAL_ROLES), '/'.join(KNOWN_NAMED_ROLES)))
return [p for p, _r in pending] return [p for p, _r in pending]
@ -385,13 +448,21 @@ def main():
for p in bad: for p in bad:
print('WILDCARD FORBIDDEN: ' + p) print('WILDCARD FORBIDDEN: ' + p)
# 门禁 3:角色词汇(离线可判定,无需 DB)。必须排在 --list / --check /
# register 三条分支之前——三条都用 role_bad 作返回口径,早于此引用即 NameError。
role_bad = audit_roles(paths)
for path, role, why in role_bad:
print('ROLE UNRESOLVABLE: %s %s' % (role, path))
print(' %s' % why)
if args.list: if args.list:
for path, role in paths: for path, role in paths:
print('%s %s' % (role, path)) print('%s %s' % (role, path))
return 0 if (not bad and not failures) else 1 return 0 if (not bad and not failures and not role_bad) else 1
if args.check: if args.check:
# 门禁 1:通配符(零容忍);门禁 2:聚合完整性(有脚本却聚合失败 = FAIL) # 门禁 1:通配符(零容忍);门禁 2:聚合完整性(有脚本却聚合失败 = FAIL);
# 门禁 3:角色名可解析性(自造角色名 → 注册必失败 → 端点 403)
print('[apps/pbls] consistency: module_scripts=%d aggregated=%d ' print('[apps/pbls] consistency: module_scripts=%d aggregated=%d '
'zero_path_declarations=%d failed_modules=%d' 'zero_path_declarations=%d failed_modules=%d'
% (len([n for n in notes if 'no scripts/load_path.py' not in n]), % (len([n for n in notes if 'no scripts/load_path.py' not in n]),
@ -400,13 +471,15 @@ def main():
len(failures))) len(failures)))
for f in failures: for f in failures:
print('AGGREGATION FAILED: ' + f) print('AGGREGATION FAILED: ' + f)
ok = (not bad) and (not failures) ok = (not bad) and (not failures) and (not role_bad)
print('[apps/pbls] check: total=%d wildcard=%d aggregation_failures=%d -> %s' print('[apps/pbls] check: total=%d wildcard=%d aggregation_failures=%d '
% (len(paths), len(bad), len(failures), 'PASS' if ok else 'FAIL')) 'unresolvable_roles=%d -> %s'
% (len(paths), len(bad), len(failures), len(role_bad),
'PASS' if ok else 'FAIL'))
return 0 if ok else 1 return 0 if ok else 1
pending = register(paths) pending = register(paths)
return 0 if (not pending and not bad and not failures) else 1 return 0 if (not pending and not bad and not failures and not role_bad) else 1
if __name__ == '__main__': if __name__ == '__main__':