diff --git a/build.sh b/build.sh index 77098aa..1d7eb1c 100644 --- a/build.sh +++ b/build.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # ===================================================================== # PBL Agent OS 验证原型(Phase 0/1)—— 一键部署脚本(test 环境) -# 端口唯一事实源:projects/pbls/env/test.json(9300);本脚本不硬编码端口,从 env 读取 +# 端口唯一事实源:projects/pbls/env/test.json(9380);本脚本不硬编码端口,从 env 读取 # 门禁:建表 37 张 + 9 项种子全绿 + self_check PASS 74/74 + py_compile 全通过 # ===================================================================== set -euo pipefail @@ -31,6 +31,9 @@ DB_USER="$(read_env "['db']['user']")" DB_PASS="$(read_env "['db']['password']")" DB_NAME="$(read_env "['db']['dbname']")" DEPLOY_PATH="$(read_env "['deploy']['path']")" +# 外网口自检用(复测门禁 ① 同时 curl 127.0.0.1 与 192.168.16.3 两个地址); +# env 无 ssh.host 时回退 127.0.0.1,不让 read_env 抛 KeyError 中断部署。 +SSH_HOST="$(python3 -c "import json;print(json.load(open('${ENV_FILE}')).get('ssh',{}).get('host') or '127.0.0.1')")" log "env 事实源: port=${PORT} db=${DB_NAME}@${DB_HOST}:${DB_PORT} deploy=${DEPLOY_PATH}" # ---- 1. 端口一致性核验(conf/config.json 必须与 env 同值)---- @@ -225,6 +228,14 @@ ACTUAL_TABLES="$(mysql --protocol=TCP -h"${DB_HOST}" -P"${DB_PORT}" -u"${DB_USER [ "${ACTUAL_TABLES}" = "37" ] || die "建表数应为 37,实际 ${ACTUAL_TABLES}" log "建表完成: pbl_* 共 ${ACTUAL_TABLES} 张" +# ---- 10b. 运行目录前置创建(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)---- +# 原脚本到步骤 13 才 mkdir logs/run,而步骤 11 已经 `tee ${APP_DIR}/logs/self_check.log`。 +# 全新克隆(仓库不含 logs/,.gitignore 也排除 *.log)时 tee 打不开文件 → +# set -euo pipefail 下整条管道非零 → die,部署在「建表已成功、自检未跑」处中断, +# 表现为门禁 ③ 表数 0/37(DDL 事务已提交但脚本没走到断言)与门禁 ②③ 全红。 +mkdir -p "${APP_DIR}/logs" "${APP_DIR}/run" +log "运行目录就位: ${APP_DIR}/logs ${APP_DIR}/run" + # ---- 11. 枚举数量契约自检(QC 退回意见 #15:必须真实可运行输出)---- "${PY}" -m pbl_appcodes.self_check | tee "${APP_DIR}/logs/self_check.log" grep -q 'SELF_CHECK pbl_appcodes: PASS 74/74' "${APP_DIR}/logs/self_check.log" \ @@ -245,16 +256,112 @@ assert s2['idempotent'] is True, '幂等校验失败' print('[seed] 幂等零差异校验通过: 8 组 74 项') PYEOF -# ---- 13. 启动应用(唯一入口 + 唯一端口)---- -log "启动 ${APP_NAME} 于端口 ${PORT}" -mkdir -p "${APP_DIR}/logs" "${APP_DIR}/run" -nohup "${PY}" "${ENTRY}" > "${APP_DIR}/logs/${APP_NAME}.out" 2>&1 & -echo $! > "${APP_DIR}/run/${APP_NAME}.pid" -sleep 5 +# ---- 12.5 RBAC 路径注册(Bug WIu0Rkn0KwB0XTjYZz0qk 修复:原脚本从不注册)---- +# 中央聚合器 apps/pbls/scripts/load_path.py 把各模块 + 宿主自有端点(含本次新增的 +# /healthz、/healthz.dspy、/api/public/health)逐条写入 permission/rolepermission。 +# 不注册的后果:进程起来了、端口监听了,但 curl 一律 401/403 → 冒烟门禁 ① HTTP!=200。 +# 先跑 --check 做通配符 + 聚合完整性门禁(rc!=0 即终止,禁止带缺口部署)。 +LOAD_PATH="${APP_DIR}/scripts/load_path.py" +[ -f "${LOAD_PATH}" ] || die "缺少 RBAC 注册入口 ${LOAD_PATH}" +log "RBAC 一致性门禁: load_path.py --check" +CHK_RBAC=0 +WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" --check || CHK_RBAC=$? +[ "${CHK_RBAC}" = "0" ] || die "RBAC 聚合门禁未通过(rc=${CHK_RBAC}):存在通配符或整模块聚合失败,终止部署" +log "RBAC 注册: load_path.py(逐条显式,无通配符)" +REG_RC=0 +WS_DIR="${WS_DIR}" "${PY}" "${LOAD_PATH}" || REG_RC=$? +if [ "${REG_RC}" != "0" ]; then + # set_role_perm.py 不在本机时 register() 返回 PENDING 清单且 rc=1; + # 这不是代码缺陷而是部署机缺工具,明确回显后终止(不静默放过 → 否则又是 403)。 + die "RBAC 注册未成功(rc=${REG_RC}):set_role_perm.py 不可用或写库失败,端点将 401/403,终止部署" +fi +log "RBAC 注册完成" -# ---- 14. 健康检查 ---- -HTTP_CODE="$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${PORT}/api/public/health" || echo 000)" -log "健康检查 HTTP ${HTTP_CODE}" -[ "${HTTP_CODE}" = "200" ] || die "健康检查失败(HTTP ${HTTP_CODE}),查看 ${APP_DIR}/logs/${APP_NAME}.out" +# ---- 12.8 落地部署目录 DEPLOY_PATH(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)---- +# 原脚本把 DEPLOY_PATH 读出来只用于最后一行日志 echo,从未创建/同步任何内容, +# 于是复测门禁 ④「ls -la /d/pbl/pbls」恒为 No such file or directory, +# 且 env/test.json 的 start_cmd(${DEPLOY_PATH}/venv/bin/python ${DEPLOY_PATH}/app/pbls.py) +# 指向的解释器与入口都不存在 → 门禁 ②③(进程数 0 / 端口未监听)不可能转绿。 +# 处置:把应用树同步到 DEPLOY_PATH,venv 以软链复用(venv 内 shebang/符号链接是 +# 绝对路径,复制换目录必坏,软链才安全),随后从 DEPLOY_PATH 启动。 +if [ "${DEPLOY_PATH}" = "${APP_DIR}" ]; then + log "DEPLOY_PATH 与仓库目录同址(${DEPLOY_PATH}),跳过同步" + RUN_DIR="${APP_DIR}" + RUN_PY="${PY}" + RUN_ENTRY="${ENTRY}" +else + log "同步应用树到部署目录: ${DEPLOY_PATH}" + mkdir -p "${DEPLOY_PATH}" + # -a 保留权限/时间并**保留软链本身**(8c 建的模块 wwwroot 软链是绝对路径,换机仍可解析); + # 排除 venv/logs/run/.git:venv 不可搬移,logs/run 由目标侧自建,.git 不属于运行态。 + rsync -a --delete \ + --exclude 'venv/' --exclude '.venv/' --exclude 'logs/' --exclude 'run/' \ + --exclude '.git/' --exclude '__pycache__/' --exclude '*.pyc' \ + "${APP_DIR}/" "${DEPLOY_PATH}/" + mkdir -p "${DEPLOY_PATH}/logs" "${DEPLOY_PATH}/run" + # venv 软链复用(目标侧 start_cmd 要求 ${DEPLOY_PATH}/venv/bin/python 可用) + if [ ! -e "${DEPLOY_PATH}/venv" ]; then + ln -sfn "${VENV_DIR}" "${DEPLOY_PATH}/venv" + 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" + [ -f "${DEPLOY_PATH}/conf/config.json" ] || die "部署目录缺配置: ${DEPLOY_PATH}/conf/config.json" + log "部署目录就位: ${DEPLOY_PATH}(app/conf/wwwroot/scripts + venv 软链 + logs/run)" + RUN_DIR="${DEPLOY_PATH}" + RUN_PY="${DEPLOY_PATH}/venv/bin/python" + RUN_ENTRY="${DEPLOY_PATH}/app/${APP_NAME}.py" +fi -log "BUILD SUCCESS: ${APP_NAME} 已运行于 http://127.0.0.1:${PORT}/ (部署路径 ${DEPLOY_PATH})" +# ---- 13. 启动应用(唯一入口 + 唯一端口,从部署目录启动)---- +# 先停旧进程(幂等重跑不撞端口):按 pid 文件 + 入口路径双口径清理。 +if [ -f "${RUN_DIR}/run/${APP_NAME}.pid" ]; then + OLD_PID="$(cat "${RUN_DIR}/run/${APP_NAME}.pid" 2>/dev/null || true)" + if [ -n "${OLD_PID:-}" ] && kill -0 "${OLD_PID}" 2>/dev/null; then + log "停止旧进程 pid=${OLD_PID}" + kill "${OLD_PID}" 2>/dev/null || true + sleep 2 + fi +fi +pkill -f "${RUN_ENTRY}" 2>/dev/null || true +sleep 1 + +log "启动 ${APP_NAME} 于端口 ${PORT}(cwd=${RUN_DIR})" +# cwd 必须是 RUN_DIR:conf/config.json 的 website.root="wwwroot" 是相对路径, +# 从别处启动会解析不到 wwwroot → 所有 .ui/.dspy 404(含 /healthz)。 +( cd "${RUN_DIR}" && nohup "${RUN_PY}" "${RUN_ENTRY}" \ + > "${RUN_DIR}/logs/${APP_NAME}.out" 2>&1 & echo $! > "${RUN_DIR}/run/${APP_NAME}.pid" ) +sleep 6 + +# 进程 + 端口双自证(对应复测门禁 ②):起不来就把日志尾部打出来再 die, +# 避免「HTTP=000 但没人知道为什么」的四轮同构复测。 +PROC_N="$(ps -ef | grep -F "${APP_NAME}.py" | grep -v grep | wc -l | tr -d '[:space:]')" +log "进程数=${PROC_N}" +if [ "${PROC_N}" = "0" ]; then + echo "----- ${RUN_DIR}/logs/${APP_NAME}.out (tail 60) -----" + tail -60 "${RUN_DIR}/logs/${APP_NAME}.out" 2>/dev/null || true + echo "----------------------------------------------------" + die "应用进程未存活(ps 计数 0),门禁 ② 不成立,详见上方日志尾部" +fi + +# ---- 14. 健康检查(两条口径都验:/healthz = 复测门禁 ①,/api/public/health = 原自检口径)---- +check_http() { + curl -s -m 8 -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || echo 000 +} +HZ_CODE="$(check_http "http://127.0.0.1:${PORT}/healthz")" +AH_CODE="$(check_http "http://127.0.0.1:${PORT}/api/public/health")" +IP_CODE="$(check_http "http://${SSH_HOST:-127.0.0.1}:${PORT}/healthz")" +log "健康检查: /healthz=${HZ_CODE} /api/public/health=${AH_CODE} 外网口 /healthz=${IP_CODE}" +if [ "${HZ_CODE}" != "200" ] || [ "${AH_CODE}" != "200" ]; then + echo "----- ${RUN_DIR}/logs/${APP_NAME}.out (tail 60) -----" + tail -60 "${RUN_DIR}/logs/${APP_NAME}.out" 2>/dev/null || true + echo "----------------------------------------------------" + die "健康检查失败(/healthz=${HZ_CODE}, /api/public/health=${AH_CODE}),门禁 ① 不成立" +fi + +# 表数终态复述(对应复测门禁 ③):部署完成后 pbl 库必须有 37 张 pbl_* 表。 +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 LIKE 'pbl\\_%'" "${DB_NAME}")" +log "终态表数: ${DB_NAME}.pbl_* = ${FINAL_TABLES}/37" +[ "${FINAL_TABLES}" = "37" ] || die "终态表数 ${FINAL_TABLES} != 37,门禁 ③ 不成立" + +log "BUILD SUCCESS: ${APP_NAME} 已运行于 http://127.0.0.1:${PORT}/ (部署路径 ${DEPLOY_PATH},/healthz=200,表数 ${FINAL_TABLES}/37)" diff --git a/conf/config.json b/conf/config.json index eb3422e..db1c819 100644 --- a/conf/config.json +++ b/conf/config.json @@ -1,9 +1,9 @@ { "app": "pbls", "app_cn": "PBL Agent OS 验证原型(Phase 0/1)", - "port": 9300, + "port": 9380, "host": "0.0.0.0", - "port_source": "projects/pbls/env/test.json(唯一事实源,本文件与其同值 9300)", + "port_source": "projects/pbls/env/test.json(唯一事实源,本文件与其同值 9380)", "debug": false, "password_key": "pbls$[env:PASSWORD_KEY]$2026", "logger": { @@ -22,14 +22,14 @@ }, "filesroot": "$[workdir]$/files", "databases": { - "pbls": { + "pbl": { "driver": "mysql", "kwargs": { "host": "127.0.0.1", "port": 3306, - "user": "pbls", - "password": "Pbls#Test2026", - "database": "pbls", + "user": "test", + "password": "hImoFMkBVbdGiQE030YGvg==", + "database": "pbl", "charset": "utf8mb4" } } @@ -38,9 +38,9 @@ "engine": "mariadb", "host": "127.0.0.1", "port": 3306, - "user": "pbls", - "password": "Pbls#Test2026", - "dbname": "pbls", + "user": "test", + "password": "hImoFMkBVbdGiQE030YGvg==", + "dbname": "pbl", "charset": "utf8mb4", "collation": "utf8mb4_general_ci", "tcp_only": true, @@ -112,34 +112,34 @@ } }, "module_dbname": { - "apppublic": "pbls", - "sqlor": "pbls", - "ahserver": "pbls", - "appbase": "pbls", - "rbac": "pbls", - "accounting": "pbls", - "world": "pbls", - "scene": "pbls", - "entity": "pbls", - "script_engine": "pbls", - "scense_runtime": "pbls", - "scense": "pbls", - "scense_game": "pbls", - "pbl_common": "pbls", - "pbl_appcodes": "pbls", - "pbl_governance": "pbls", - "pbl_blueprint": "pbls", - "pbl_template": "pbls", - "pbl_validation": "pbls", - "pbl_compiler": "pbls", - "pbl_agent_runtime": "pbls", - "pbl_evidence": "pbls", - "pbl_assessment": "pbls", - "pbl_domain_ext": "pbls", - "pbl_scense_ext": "pbls", - "pbl_runtime_ext": "pbls", - "pbl_kdb_ext": "pbls", - "pbl_analytics": "pbls" + "apppublic": "pbl", + "sqlor": "pbl", + "ahserver": "pbl", + "appbase": "pbl", + "rbac": "pbl", + "accounting": "pbl", + "world": "pbl", + "scene": "pbl", + "entity": "pbl", + "script_engine": "pbl", + "scense_runtime": "pbl", + "scense": "pbl", + "scense_game": "pbl", + "pbl_common": "pbl", + "pbl_appcodes": "pbl", + "pbl_governance": "pbl", + "pbl_blueprint": "pbl", + "pbl_template": "pbl", + "pbl_validation": "pbl", + "pbl_compiler": "pbl", + "pbl_agent_runtime": "pbl", + "pbl_evidence": "pbl", + "pbl_assessment": "pbl", + "pbl_domain_ext": "pbl", + "pbl_scense_ext": "pbl", + "pbl_runtime_ext": "pbl", + "pbl_kdb_ext": "pbl", + "pbl_analytics": "pbl" }, "load_order": [ "apppublic", diff --git a/scripts/load_path.py b/scripts/load_path.py index e839b0a..0584888 100644 --- a/scripts/load_path.py +++ b/scripts/load_path.py @@ -105,6 +105,21 @@ MILESTONE_DECLARATIONS = [ }, ] +# ── 宿主自有端点(Bug WIu0Rkn0KwB0XTjYZz0qk 修复)──────────────────────────── +# 复测门禁 ① 用 `curl http://127.0.0.1:9380/healthz` 判活,但此前: +# (a) 仓库里根本没有 /healthz 端点(grep -rn healthz apps/pbls 零命中); +# (b) build.sh 自检走的是另一条 /api/public/health,同样不存在; +# (c) 即便补了文件,本聚合器只扫 modules/*,宿主 wwwroot 下的端点无人登记 → 401/403。 +# 三者叠加 = 进程活着也必然 HTTP!=200,冒烟门禁 ① 恒红。此处逐条显式登记(禁通配符), +# role='any':健康探针必须免鉴权,否则 curl 无 session 拿不到 200(探针不返回业务数据, +# 只回 status/时间戳/pbl_* 表计数,无敏感字段,放开匿名访问是安全且必要的)。 +HOST_PATHS = [ + ('/healthz', 'any'), + ('/healthz.dspy', 'any'), + ('/api/public/health', 'any'), + ('/api/public/health.dspy', 'any'), +] + # ── pbl_evidence 硬编码兜底清单(与模块层一致;模块脚本 import 失败时由此登记)──── PBL_EVIDENCE_FALLBACK = [ ('/pbl_evidence/index.ui', 'logined'), @@ -294,6 +309,20 @@ def collect(): # 注:此处无需再判 got is None —— 上方各分支在 got is None 时均已 continue # (M11b-2c-T3 QC 退回意见 #14 删除的不可达分支)。 + # 宿主自有端点(healthz 探针等):模块聚合扫不到 apps/pbls/wwwroot,必须在此并入。 + # 与模块路径同规则去重,冲突时保留更严格 role(探针登记为 any,不会被模块覆盖, + # 因为没有任何模块会声明 /healthz)。 + for p, r in HOST_PATHS: + if p in merged and merged[p] != r: + keep = _stricter(merged[p], r) + conflicts.append('apps/pbls(host): %s role %s vs %s -> keep %s (stricter)' + % (p, merged[p], r, keep)) + merged[p] = keep + else: + merged[p] = r + notes.append('apps/pbls(host): aggregated %d host paths (healthz probes)' + % len(HOST_PATHS)) + for c in conflicts: notes.append('[conflict] ' + c) return sorted((p, merged[p]) for p in merged), notes, failures diff --git a/wwwroot/api/public/health/index.dspy b/wwwroot/api/public/health/index.dspy new file mode 100644 index 0000000..7fa7262 --- /dev/null +++ b/wwwroot/api/public/health/index.dspy @@ -0,0 +1,58 @@ +# apps/pbls/wwwroot/api/public/health/index.dspy —— 部署冒烟健康探针(无鉴权 / any 角色) +# +# 【为什么必须有这个文件】Bug WIu0Rkn0KwB0XTjYZz0qk 第 4 轮复测门禁 ① 用 +# curl -s -m 8 -o /tmp/h1.txt -w 'HTTP=%{http_code}' http://127.0.0.1:9380/healthz +# 作为「应用是否活着」的判据,但仓库里此前**根本不存在 /healthz 端点** +# (grep -rn healthz apps/pbls/app apps/pbls/wwwroot 零命中),而 build.sh 步骤 14 +# 自检用的却是另一条路径 /api/public/health(同样不存在)。两条探针路径都缺 → +# 即便进程起来、端口监听,门禁 ① 也必然 HTTP=000/404,冒烟永远不可能转绿。 +# 本文件补齐 /healthz;同批补 wwwroot/api/public/health/index.dspy 保持 build.sh 原口径。 +# +# 【URL 映射】ahserver 按 website.root=wwwroot + processors['.dspy']='dspy' 解析: +# wwwroot/api/public/health/index.dspy -> GET /healthz +# 另在 wwwroot/healthz.dspy 放同逻辑副本 -> GET /api/public/health(兼容带扩展名探测)。 +# +# 【dspy 规约(dspy-file-implementation-spec 硬规则)】 +# * 零 import:json / datetime 由框架预加载,直接裸用; +# * 每个分支显式 return(框架把文件包进 async def,裸表达式返回 None → 500); +# * dict 返回内**不用 f-string**(exec 误读 '}' → SyntaxError),改字符串拼接; +# * try/except 兜底:对象端点异常也返回合法 JSON({"status":"error",...}),不抛 500; +# * json.dumps(..., default=str) 防 Decimal/datetime 序列化炸; +# * 不返回任何敏感字段(库口令/密钥一律不出现在响应里)。 +# +# 【语义约定】status=ok 只表示「进程活着 + HTTP 栈可服务 + 路由/RBAC 已放行」。 +# DB 探测是**附加诊断信息**(db.tables / db.error),DB 不通**不**把 status 降级为 +# error —— 否则库抖动会让「进程存活」探针失真,排障时分不清是进程死了还是库挂了。 +# 表数门禁(37 张)由 build.sh 步骤 10 的硬断言负责,不在探针里做判定。 +try: + result = { + 'status': 'ok', + 'app': 'pbls', + 'endpoint': '/api/public/health', + 'ts': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), + } + + # ---- 附加诊断:pbl_* 业务表数量(只读 information_schema,失败不影响 status)---- + db_info = {'ok': False, 'tables': None, 'error': None} + try: + env = request._run_ns + async with get_sor_context(env, 'pbl_common') as sor: + rows = await sor.sqlExe( + "SELECT COUNT(*) AS cnt FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name LIKE ${pat}$", + {'pat': 'pbl\\_%'}) + if rows: + first = rows[0] + cnt = getattr(first, 'cnt', None) + if cnt is None and hasattr(first, 'keys'): + cnt = dict(first).get('cnt') + db_info['tables'] = int(cnt) if cnt is not None else 0 + db_info['ok'] = True + except Exception as dbe: + db_info['error'] = str(dbe) + result['db'] = db_info + + return json.dumps(result, ensure_ascii=False, default=str) +except Exception as e: + return json.dumps({'status': 'error', 'app': 'pbls', 'endpoint': '/api/public/health', + 'error': str(e)}, ensure_ascii=False, default=str) diff --git a/wwwroot/healthz.dspy b/wwwroot/healthz.dspy new file mode 100644 index 0000000..f6a343d --- /dev/null +++ b/wwwroot/healthz.dspy @@ -0,0 +1,58 @@ +# apps/pbls/wwwroot/healthz/index.dspy —— 部署冒烟健康探针(无鉴权 / any 角色) +# +# 【为什么必须有这个文件】Bug WIu0Rkn0KwB0XTjYZz0qk 第 4 轮复测门禁 ① 用 +# curl -s -m 8 -o /tmp/h1.txt -w 'HTTP=%{http_code}' http://127.0.0.1:9380/healthz +# 作为「应用是否活着」的判据,但仓库里此前**根本不存在 /healthz 端点** +# (grep -rn healthz apps/pbls/app apps/pbls/wwwroot 零命中),而 build.sh 步骤 14 +# 自检用的却是另一条路径 /api/public/health(同样不存在)。两条探针路径都缺 → +# 即便进程起来、端口监听,门禁 ① 也必然 HTTP=000/404,冒烟永远不可能转绿。 +# 本文件补齐 /healthz;同批补 wwwroot/api/public/health/index.dspy 保持 build.sh 原口径。 +# +# 【URL 映射】ahserver 按 website.root=wwwroot + processors['.dspy']='dspy' 解析: +# wwwroot/healthz/index.dspy -> GET /healthz +# 另在 wwwroot/healthz.dspy 放同逻辑副本 -> GET /healthz.dspy(兼容带扩展名探测)。 +# +# 【dspy 规约(dspy-file-implementation-spec 硬规则)】 +# * 零 import:json / datetime 由框架预加载,直接裸用; +# * 每个分支显式 return(框架把文件包进 async def,裸表达式返回 None → 500); +# * dict 返回内**不用 f-string**(exec 误读 '}' → SyntaxError),改字符串拼接; +# * try/except 兜底:对象端点异常也返回合法 JSON({"status":"error",...}),不抛 500; +# * json.dumps(..., default=str) 防 Decimal/datetime 序列化炸; +# * 不返回任何敏感字段(库口令/密钥一律不出现在响应里)。 +# +# 【语义约定】status=ok 只表示「进程活着 + HTTP 栈可服务 + 路由/RBAC 已放行」。 +# DB 探测是**附加诊断信息**(db.tables / db.error),DB 不通**不**把 status 降级为 +# error —— 否则库抖动会让「进程存活」探针失真,排障时分不清是进程死了还是库挂了。 +# 表数门禁(37 张)由 build.sh 步骤 10 的硬断言负责,不在探针里做判定。 +try: + result = { + 'status': 'ok', + 'app': 'pbls', + 'endpoint': '/healthz', + 'ts': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), + } + + # ---- 附加诊断:pbl_* 业务表数量(只读 information_schema,失败不影响 status)---- + db_info = {'ok': False, 'tables': None, 'error': None} + try: + env = request._run_ns + async with get_sor_context(env, 'pbl_common') as sor: + rows = await sor.sqlExe( + "SELECT COUNT(*) AS cnt FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name LIKE ${pat}$", + {'pat': 'pbl\\_%'}) + if rows: + first = rows[0] + cnt = getattr(first, 'cnt', None) + if cnt is None and hasattr(first, 'keys'): + cnt = dict(first).get('cnt') + db_info['tables'] = int(cnt) if cnt is not None else 0 + db_info['ok'] = True + except Exception as dbe: + db_info['error'] = str(dbe) + result['db'] = db_info + + return json.dumps(result, ensure_ascii=False, default=str) +except Exception as e: + return json.dumps({'status': 'error', 'app': 'pbls', 'endpoint': '/healthz', + 'error': str(e)}, ensure_ascii=False, default=str) diff --git a/wwwroot/healthz/index.dspy b/wwwroot/healthz/index.dspy new file mode 100644 index 0000000..f6a343d --- /dev/null +++ b/wwwroot/healthz/index.dspy @@ -0,0 +1,58 @@ +# apps/pbls/wwwroot/healthz/index.dspy —— 部署冒烟健康探针(无鉴权 / any 角色) +# +# 【为什么必须有这个文件】Bug WIu0Rkn0KwB0XTjYZz0qk 第 4 轮复测门禁 ① 用 +# curl -s -m 8 -o /tmp/h1.txt -w 'HTTP=%{http_code}' http://127.0.0.1:9380/healthz +# 作为「应用是否活着」的判据,但仓库里此前**根本不存在 /healthz 端点** +# (grep -rn healthz apps/pbls/app apps/pbls/wwwroot 零命中),而 build.sh 步骤 14 +# 自检用的却是另一条路径 /api/public/health(同样不存在)。两条探针路径都缺 → +# 即便进程起来、端口监听,门禁 ① 也必然 HTTP=000/404,冒烟永远不可能转绿。 +# 本文件补齐 /healthz;同批补 wwwroot/api/public/health/index.dspy 保持 build.sh 原口径。 +# +# 【URL 映射】ahserver 按 website.root=wwwroot + processors['.dspy']='dspy' 解析: +# wwwroot/healthz/index.dspy -> GET /healthz +# 另在 wwwroot/healthz.dspy 放同逻辑副本 -> GET /healthz.dspy(兼容带扩展名探测)。 +# +# 【dspy 规约(dspy-file-implementation-spec 硬规则)】 +# * 零 import:json / datetime 由框架预加载,直接裸用; +# * 每个分支显式 return(框架把文件包进 async def,裸表达式返回 None → 500); +# * dict 返回内**不用 f-string**(exec 误读 '}' → SyntaxError),改字符串拼接; +# * try/except 兜底:对象端点异常也返回合法 JSON({"status":"error",...}),不抛 500; +# * json.dumps(..., default=str) 防 Decimal/datetime 序列化炸; +# * 不返回任何敏感字段(库口令/密钥一律不出现在响应里)。 +# +# 【语义约定】status=ok 只表示「进程活着 + HTTP 栈可服务 + 路由/RBAC 已放行」。 +# DB 探测是**附加诊断信息**(db.tables / db.error),DB 不通**不**把 status 降级为 +# error —— 否则库抖动会让「进程存活」探针失真,排障时分不清是进程死了还是库挂了。 +# 表数门禁(37 张)由 build.sh 步骤 10 的硬断言负责,不在探针里做判定。 +try: + result = { + 'status': 'ok', + 'app': 'pbls', + 'endpoint': '/healthz', + 'ts': datetime.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'), + } + + # ---- 附加诊断:pbl_* 业务表数量(只读 information_schema,失败不影响 status)---- + db_info = {'ok': False, 'tables': None, 'error': None} + try: + env = request._run_ns + async with get_sor_context(env, 'pbl_common') as sor: + rows = await sor.sqlExe( + "SELECT COUNT(*) AS cnt FROM information_schema.tables " + "WHERE table_schema = DATABASE() AND table_name LIKE ${pat}$", + {'pat': 'pbl\\_%'}) + if rows: + first = rows[0] + cnt = getattr(first, 'cnt', None) + if cnt is None and hasattr(first, 'keys'): + cnt = dict(first).get('cnt') + db_info['tables'] = int(cnt) if cnt is not None else 0 + db_info['ok'] = True + except Exception as dbe: + db_info['error'] = str(dbe) + result['db'] = db_info + + return json.dumps(result, ensure_ascii=False, default=str) +except Exception as e: + return json.dumps({'status': 'error', 'app': 'pbls', 'endpoint': '/healthz', + 'error': str(e)}, ensure_ascii=False, default=str) diff --git a/wwwroot/index.ui b/wwwroot/index.ui index dde7a00..79cf849 100644 --- a/wwwroot/index.ui +++ b/wwwroot/index.ui @@ -176,7 +176,7 @@ "type": "Label", "id": "status_port", "options": { - "text": "端口:9300", + "text": "端口:9380", "cls": "pbls-status" } }