From bf00609c1534618faca3a468a5eafe2372fa394a Mon Sep 17 00:00:00 2001 From: "agent.develop" Date: Wed, 30 Sep 2026 18:15:25 +0800 Subject: [PATCH] =?UTF-8?q?approve:=20=E4=BF=AE=E5=A4=8D=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E8=BF=AD=E4=BB=A3=202=20=E4=B8=AA=E5=8A=9F=E8=83=BD=20Bug?= =?UTF-8?q?=EF=BC=88pbls-=E5=88=9D=E5=A7=8B=E8=BF=AD=E4=BB=A3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- app/pbls.py | 7 +- build.sh | 194 +++++++++++++++++++++++++++++++----- conf/config.json | 9 +- requirements.txt | 4 +- scripts/ddl/pbls_tables.sql | 138 ++++--------------------- scripts/load_path.py | 93 +++++++++++++++-- 7 files changed, 289 insertions(+), 158 deletions(-) mode change 100644 => 100755 build.sh diff --git a/README.md b/README.md index 672e8cd..316cf4a 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ venv 创建与 pip 升级 + 完整 7b 段」后 `bash` 执行、`tee` 捕获得 两处如实说明(均属本任务范围外的既有问题,已作为发现项上报 PM,未在本任务内改动): 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 内可用」, **不代表**运行时依赖(ahserver/sqlor/…)在本机已装齐,也不代表 `build.sh` 全流程在本机跑通。 2. 首轮曾按「截取第 1 行起的全部前缀」执行,`build.sh` 第 4 步 DDL 方言门禁 die: diff --git a/app/pbls.py b/app/pbls.py index 33b6f62..9314058 100644 --- a/app/pbls.py +++ b/app/pbls.py @@ -18,6 +18,7 @@ * webapp(init) 置于 __main__ 守卫内,被 import 不触发启动 """ +import asyncio import json import os @@ -108,7 +109,11 @@ def init(): # ---- 引用模块(只读挂载,不改基表) load_world() 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_scense_runtime() load_scense() diff --git a/build.sh b/build.sh old mode 100644 new mode 100755 index 4d1e68b..a4442bf --- a/build.sh +++ b/build.sh @@ -2,7 +2,8 @@ # ===================================================================== # PBL Agent OS 验证原型(Phase 0/1)—— 一键部署脚本(test 环境) # 端口唯一事实源: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 @@ -108,7 +109,8 @@ log "硬门禁 6.2 通过: ServerEnv()(L${ENV_LINE}) < load_rbac()(L${RBAC_LINE} # 表数不可信(且旧断言 36 与 DDL 实际 37 张不符,新增 pbl_compile_task 后未同步)。 # 修复:改为「先剥 SQL 注释 + 字符串字面量(含 COMMENT 串),再在纯 DDL 正文上扫描」, # 由 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}" DDL_GATE="${APP_DIR}/scripts/ddl_gate_check.py" [ -f "${DDL_GATE}" ] || die "缺少 DDL 门禁扫描器 ${DDL_GATE}(6.4 依赖,禁止回退到原文 grep)" @@ -118,11 +120,14 @@ SELFTEST_RC=0 python3 "${DDL_GATE}" --selftest || 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 -python3 "${DDL_GATE}" --ddl "${DDL_FILE}" --expect-tables 37 --expect-autoinc 37 || GATE_RC=$? -[ "${GATE_RC}" = "0" ] || die "硬门禁 6.4 未通过(ddl_gate_check.py rc=${GATE_RC}):非 mariadb 方言关键字命中 / 表数≠37 / BIGINT AUTO_INCREMENT≠37,详见上方回显" -log "硬门禁 6.4 通过: mariadb 方言(剥注释+COMMENT 串后零命中),37 表 / 37 处 BIGINT AUTO_INCREMENT" +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 方言关键字命中 / 表数≠32 / BIGINT AUTO_INCREMENT≠32,详见上方回显" +log "硬门禁 6.4 通过: mariadb 方言(剥注释+COMMENT 串后零命中),32 表 / 32 处 BIGINT AUTO_INCREMENT" # ---- 5. 依赖声明核验(6.5)---- for dep in ahserver sqlor apppublic appbase rbac PyMySQL; do @@ -168,7 +173,7 @@ fi "${PY}" -m pip install --upgrade pip wheel setuptools # [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)。 # 改为:机构 wheelhouse 离线供给(--find-links,绝对路径,不依赖 cwd)+ 公网 pypi.org 取第三方包 # (目标机实测 https://pypi.org/simple/pymysql/ HTTP=200)。 @@ -336,11 +341,35 @@ log "py_compile 全部通过" log "执行 DDL: ${DDL_FILE}" mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER}" -p"${DB_PASS}" \ --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}" \ - -N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name LIKE 'pbl\\_%'" "${DB_NAME}")" -# Q4 修复:与 DDL 实际表数对齐(36 → 37,新增 pbl_compile_task) -[ "${ACTUAL_TABLES}" = "37" ] || die "建表数应为 37,实际 ${ACTUAL_TABLES}" -log "建表完成: pbl_* 共 ${ACTUAL_TABLES} 张" + -N -B -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${DB_NAME}' AND table_name IN (${HAND_TABLES})" "${DB_NAME}")" +# Q4 修复:与 DDL 实际表数对齐;[2026-09-28 方案 A] governance/template 6 表迁出 +# 至模块 models 链(第 10a 步建),本文件手写 DDL 37→32。 +[ "${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 修复)---- # 原脚本到步骤 13 才 mkdir logs/run,而步骤 11 已经 `tee ${APP_DIR}/logs/self_check.log`。 @@ -371,17 +400,71 @@ log "枚举数量契约自检通过: 74 项 / 自检全绿(无 FAIL)" # ---- 12. 9 项种子数据幂等注入(重复执行零差异)---- 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' -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 -# [deploy_test R12 定点补丁] seed_all 是 async def(modules/pbl_appcodes/pbl_appcodes/seed.py:460), -# 原脚本同步调用 → TypeError: Object of type coroutine is not JSON serializable -# + RuntimeWarning: coroutine 'seed_all' was never awaited(r12d 实测,建表 37/37 后种子段中断)。 -# 修法:asyncio.run 驱动协程,两轮各自独立事件循环(幂等对比口径不变)。 -s1 = asyncio.run(seed_all()) -print('[seed round1]', json.dumps(s1, ensure_ascii=False, sort_keys=True)) -s2 = asyncio.run(seed_all()) -print('[seed round2]', json.dumps(s2, ensure_ascii=False, sort_keys=True)) +# [deploy_test R12 定点补丁] seed_all 是 async def,原脚本同步调用 → TypeError: +# Object of type coroutine is not JSON serializable + RuntimeWarning: never awaited。 +# [2026-09-29 修复] 两轮必须跑在**同一个事件循环**里,不能各自 asyncio.run(): +# sqlor 连接池的 Future 绑定首次建池的 loop,第二次 asyncio.run() 新建 loop 后 +# 复用同一连接池 → "got Future attached to a different loop"(build_run7 实测, +# 第 1 轮 inserted=82 成功、第 2 轮崩)。幂等对比口径不变:同 loop 内顺序两轮, +# 第 2 轮仍须零插入零更新。 +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 三个键, # 但 seed_all 真实返回契约(seed.py:494-499)是 ok/groups/items/inserted/updated/skipped/per_group, # 无上述键 → 同步调用修好后必然 KeyError。改按真实返回键断言,幂等口径不变(第二轮零插入零更新)。 @@ -398,6 +481,36 @@ PYEOF # 先跑 --check 做通配符 + 聚合完整性门禁(rc!=0 即终止,禁止带缺口部署)。 LOAD_PATH="${APP_DIR}/scripts/load_path.py" [ -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" CHK_RBAC=0 WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" --check || CHK_RBAC=$? @@ -433,10 +546,13 @@ else # 可能未装 rsync,原实现直接调用 → command not found → set -e 下 die, # 部署目录 /d/pbl/pbls 永远建不出来(复测门禁 ④ 恒红)。 # 无 rsync 时退化为 tar 管道复制(POSIX 必备,保留权限与软链本身),并显式清理运行态目录。 + # [Bug r13-start2 修复·续] exclude 模式必须无尾斜杠:'venv/' 只匹配目录,venv 是软链时 + # 不命中 → --delete 会把部署侧 venv 软链删掉(实测发生)。并排除 venv.stale-* 保留目录。 if command -v rsync >/dev/null 2>&1; then rsync -a --delete \ - --exclude 'venv/' --exclude '.venv/' --exclude 'logs/' --exclude 'run/' \ - --exclude '.git/' --exclude '__pycache__/' --exclude '*.pyc' \ + --exclude 'venv' --exclude '.venv' --exclude 'venv.stale-*' --exclude '.venv.stale-*' \ + --exclude 'logs' --exclude 'run' \ + --exclude '.git' --exclude '__pycache__' --exclude '*.pyc' \ "${APP_DIR}/" "${DEPLOY_PATH}/" else log "rsync 不可用,退化 tar 管道同步(保留权限/软链)" @@ -449,8 +565,21 @@ else fi mkdir -p "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run" # 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- + # 再建软链——不跨文件系统 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" + log "venv 软链建立: ${DEPLOY_PATH}/venv -> ${VENV_DIR}" fi [ -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" @@ -507,10 +636,21 @@ if [ "${HZ_CODE}" != "200" ] || [ "${AH_CODE}" != "200" ]; then die "健康检查失败(/healthz=${HZ_CODE}, /api/public/health=${AH_CODE}),门禁 ① 不成立" 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}" \ + -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}")" -log "终态表数: ${DB_NAME}.pbl_* = ${FINAL_TABLES}/37" -[ "${FINAL_TABLES}" = "37" ] || die "终态表数 ${FINAL_TABLES} != 37,门禁 ③ 不成立" +log "终态表数: 关键表 ${FINAL_TABLES}/38 在场(pbl_* 总计 ${TOTAL_PBL} 张,含模块自建/扩展表)" +[ "${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)" diff --git a/conf/config.json b/conf/config.json index db1c819..ad3a8fe 100644 --- a/conf/config.json +++ b/conf/config.json @@ -29,7 +29,7 @@ "port": 3306, "user": "test", "password": "hImoFMkBVbdGiQE030YGvg==", - "database": "pbl", + "db": "pbl", "charset": "utf8mb4" } } @@ -51,7 +51,14 @@ } }, "website": { + "port": 9380, "root": "wwwroot", + "paths": [ + [ + "$[workdir]$/wwwroot", + "" + ] + ], "indexes": [ "index.ui", "index.tmpl", diff --git a/requirements.txt b/requirements.txt index 17f3fd5..250fb73 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # PBL Agent OS 验证原型(Phase 0/1)—— 应用依赖声明 # 平台基础依赖(ahserver / sqlor / apppublic / appbase / rbac)由机构私有源提供, # 通过 --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,部署卡在依赖安装)。 # 平台基础包改由机构 wheelhouse 离线供给(--find-links 相对本文件定位 WS/wheelhouse), # 第三方包走公网 pypi.org(目标机实测 HTTP=200 可达)。 @@ -13,7 +13,7 @@ # 已由 build.sh 硬门禁 6.6(scripts/check_requirements_satisfiable.py)静态把关: # 幻影包黑名单(pybricks / mysqlclient / hashlib-additional / 本地 pbl_* 模块)、 # 标准库名误当第三方声明、基座包 == 版本与 wheelhouse 实物逐字一致、 -# wheelhouse 覆盖完整性、门禁 6.5 六项声明在位、失效内网源 10.20.30.41 残留、 +# wheelhouse 覆盖完整性、门禁 6.5 六项声明在位、失效内网源 DEAD-INTERNAL-PYPI-REMOVED 残留、 # 相对路径 --find-links(pip 不解析)——并同步扫描全部 modules/*/pyproject.toml 同类缺陷。 # 基座版本口径 = env/test.json deps.corrected_requirements_hint(业主 2026-09-25 修正)。 diff --git a/scripts/ddl/pbls_tables.sql b/scripts/ddl/pbls_tables.sql index b581d9e..99e007b 100644 --- a/scripts/ddl/pbls_tables.sql +++ b/scripts/ddl/pbls_tables.sql @@ -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) -- 规则: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 打头 -- 禁止 FOREIGN KEY / 禁止 ENUM / 禁止原生 TIMESTAMP(用 DATETIME) -- append-only 表无 update_time、无物理删除(11 张,见文末清单) @@ -13,75 +17,14 @@ SET NAMES utf8mb4; 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) @@ -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)'; -- ===================================================================== --- 模块 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 级质量状态) @@ -880,10 +784,12 @@ SET FOREIGN_KEY_CHECKS = 1; -- ===================================================================== -- 契约自检(供 build.sh / QC grep 核验) --- 表总数:37(pbl_governance 3 + pbl_blueprint 11 + pbl_template 2 + pbl_validation 3 --- + pbl_compiler 5 + pbl_agent_runtime 4 + pbl_evidence 4 + pbl_assessment 3 --- + pbl_domain_ext 1 + pbl_analytics 1 = 37) --- 注:pbl_compiler 由 4 增至 5(新增 pbl_compile_task),build.sh 门禁 6.4 表数断言同步 36→37 +-- 表总数:32(pbl_blueprint 11 + pbl_validation 3 + pbl_compiler 5 +-- + pbl_agent_runtime 4 + pbl_evidence 4 + pbl_assessment 3 +-- + pbl_domain_ext 1 + pbl_analytics 1 = 32) +-- 注: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): -- 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 diff --git a/scripts/load_path.py b/scripts/load_path.py index 0584888..31ba4b7 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -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): @@ -349,17 +391,38 @@ def register(paths, verbose=True): '(rerun inside the app venv on the deploy host)' % len(paths)) return [p for p, _r in paths] py = sys.executable or 'python3' + # [2026-09-29 修复] 原先 stdout/stderr 全 DEVNULL,工具那句 + # `ERROR: role not found: *.user` 被丢掉,只剩 PENDING 清单 —— 报错不可行动 + # (用户纪律:报错必须真实可行动)。改为捕获并回显首行原因。 pending = [] + reasons = {} for path, role in paths: - rc = subprocess.call([py, tool, role, path], - stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - if rc != 0: + proc = subprocess.run([py, tool, role, path], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, timeout=120) + if proc.returncode != 0: 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: print('[apps/pbls] rbac paths: total=%d ok=%d pending=%d' % (len(paths), len(paths) - len(pending), len(pending))) for path, role in pending: 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] @@ -385,13 +448,21 @@ def main(): for p in bad: 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: for path, role in paths: 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: - # 门禁 1:通配符(零容忍);门禁 2:聚合完整性(有脚本却聚合失败 = FAIL) + # 门禁 1:通配符(零容忍);门禁 2:聚合完整性(有脚本却聚合失败 = FAIL); + # 门禁 3:角色名可解析性(自造角色名 → 注册必失败 → 端点 403) print('[apps/pbls] consistency: module_scripts=%d aggregated=%d ' 'zero_path_declarations=%d failed_modules=%d' % (len([n for n in notes if 'no scripts/load_path.py' not in n]), @@ -400,13 +471,15 @@ def main(): len(failures))) for f in failures: print('AGGREGATION FAILED: ' + f) - ok = (not bad) and (not failures) - print('[apps/pbls] check: total=%d wildcard=%d aggregation_failures=%d -> %s' - % (len(paths), len(bad), len(failures), 'PASS' if ok else 'FAIL')) + ok = (not bad) and (not failures) and (not role_bad) + print('[apps/pbls] check: total=%d wildcard=%d aggregation_failures=%d ' + 'unresolvable_roles=%d -> %s' + % (len(paths), len(bad), len(failures), len(role_bad), + 'PASS' if ok else 'FAIL')) return 0 if ok else 1 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__':