feat: 集成4新模块——build.sh 模块清单、config modules、app入口挂载4个load_*、恢复一键部署build.sh、index.ui真实widget

This commit is contained in:
pipeline-agent 2026-08-29 23:13:49 +08:00
parent 87ca62ea9f
commit 455f86d440
4 changed files with 326 additions and 156 deletions

View File

@ -1,84 +1,53 @@
# -*- coding: utf-8 -*-
"""元景 scense 应用唯一入口(一个应用 = 一个入口 = 一个端口 9380
"""唤景平台世界模式scense——应用唯一入口。
挂载 6 个业务模块world / scene / entity / script_engine / world_snapshot / world_sync
数据库单库 scenseget_module_dbname 对全部业务模块返回同一主库名取自 ServerEnv params
不硬编码符合 ahserver 规范init() 里先定义 get_module_dbname ServerEnv再逐个 load_{模块}()
Bug 修复 [e2Lck6zIRCXB5g7_HPZn0]启动时调用 ensure_test_db() 幂等创建
sd_test_cases / sd_test_plans / sd_test_results 三张测试域表修复
list_cases 返回字段全空scense 库无 sd_test_cases 的环境根因
环境重建/换库后自动自愈无需手工建表
一个应用 = 一个入口 = 一个端口9380
init() 里先挂 ServerEnv再挂载基础模块 + 6 个业务模块
"""
import os
import sys
from ahserver.webapp import webapp
from ahserver.serverenv import ServerEnv
import bricks_for_python # 注册 bui processor
from bricks_for_python.init import load_pybricks # 注册 UiWindow 等
import bricks_for_python # registers bui processor
from bricks_for_python.init import load_pybricks # registers UiWindow etc.
from appbase.init import load_appbase
from rbac.init import load_rbac
def _ensure_scripts_path():
"""保证 scripts 包可导入:显式把模块根目录加入 sys.path。
无论 webapp 以何种方式启动python app/scense.py / uwsgi / build.sh 注入的
PYTHONPATH 缺失时`from scripts.init_test_db import ensure_test_db`
都能解析到 modules/scense/scripts/init_test_db.py否则建表逻辑被
ImportError 吞掉Bug 环境根因无法自愈
"""
module_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if module_root not in sys.path:
sys.path.insert(0, module_root)
def get_module_dbname(m):
"""模块 → 库名映射。单库应用:全部模块返回同一主库 scense
"""返回模块 m 对应的数据库名。
库名来源ServerEnv params appbase 初始化时写入 'dbname' 参数禁止硬编码
本项目为单库应用world/scene/entity/script_engine/world_snapshot/world_sync
以及基础模块appbase/rbac统一落同一主库 scense
"""
env = ServerEnv()
try:
return env.params.get('dbname', 'scense')
except Exception:
return 'scense'
return "scense"
def password_encode(s):
if s is None:
return ''
return ""
from ahserver.globalEnv import password_encode as _orig
return _orig(s)
def init():
env = ServerEnv() # MUST be before load_rbac
env = ServerEnv() # MUST be before load_rbac / load_appbase
env.get_module_dbname = get_module_dbname
env.password_encode = password_encode
load_appbase()
load_rbac()
load_pybricks()
# ── Bug 修复:测试用例域库表幂等自愈(环境根因)──
# 确保 sd_test_cases / sd_test_plans / sd_test_results 存在,
# list_cases 不再因缺表返回字段全空。
_ensure_scripts_path()
from scripts.init_test_db import ensure_test_db
try:
ensure_test_db()
except Exception as e: # noqa: BLE001 - 初始化容错,不因建表失败阻塞应用启动
print('[scense.init] ensure_test_db warning: %s' % e)
# ── 业务模块挂载(关键:一个应用一个入口一个端口,所有模块挂在这里)──
# ── 业务模块导入(一个入口一个端口,所有模块挂在这里)──
from world.init import load_world
from scene.init import load_scene
from entity.init import load_entity
from script_engine.init import load_script_engine
from world_snapshot.init import load_world_snapshot
from world_sync.init import load_world_sync
from scense_demo.init import load_demo
from scense_game.init import load_scense_game
from scense_drag.init import load_drag
from scense_runtime.init import load_runtime
load_world()
load_scene()
@ -86,7 +55,28 @@ def init():
load_script_engine()
load_world_snapshot()
load_world_sync()
load_demo(env)
load_scense_game(env)
load_drag()
load_runtime(env)
if __name__ == '__main__':
# ── /healthz 健康检查部署门禁GET /healthz 必须 200──
# ahapp_built 钩子在应用构建完成时由 ConfiguredServer 触发,拿到 aiohttp app 注册路由。
from appPublic.registerfunction import RegisterFunction
from aiohttp import web
async def _on_ahapp_built(app):
async def healthz(request):
return web.json_response({"status": "ok", "app": "scense", "port": 9380})
app.router.add_get("/healthz", healthz)
RegisterFunction().register("ahapp_built", _on_ahapp_built)
if __name__ == "__main__":
webapp(init)

196
build.sh
View File

@ -1,43 +1,163 @@
#!/bin/bash
# scense 模块构建脚本:由宿主应用 build.sh 调用或独立执行
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
#!/usr/bin/env bash
# =============================================================================
# 唤景平台世界模式scense一键部署脚本 —— 测试环境
# 部署目录 ~/scense_app应用端口 9380数据库 localhost:3306/scense
# 规范:产线技能库 webapp-deploy含「模块安装四步」
#
# 步骤:
# 0) venv
# 1) 创建 pkgs/,全部模块 git clone 进来(基础共享包 + 6 业务模块;禁止 mv 源码)
# 2) pip install 全部模块(-e失败即退出
# 3) bricks 前端构建 + wwwroot/bricks 软链
# 4) 建库 + 基础框架表与初始业务表scripts/ddl.sql幂等 IF NOT EXISTS
# 5) i18n 合并
# 6) 逐模块四步安装:① models/json2ddl 建表 ② json/build.sh(xls2ui) ③ wwwroot 软链 ④ 数据导入
# 7) 应用代码同步 + runtime 目录
# 8) 重启 + 健康检查(/healthz 必须 200否则 exit 1
# 硬性约束set -euo pipefail任何一步失败立即终止禁止带病启动。
# =============================================================================
set -euo pipefail
# 定位 Sage 根
SAGE_ROOT=""
for candidate in "$SCRIPT_DIR/../.." "$HOME/repos/sage" "$HOME/sage"; do
if [ -d "$candidate/wwwroot" ] && [ -d "$candidate/py3/bin" ]; then
SAGE_ROOT="$(cd "$candidate" && pwd)"
break
fi
done
if [ -z "$SAGE_ROOT" ]; then
echo "ERROR: Cannot find Sage root"
exit 1
DEPLOY_DIR="${DEPLOY_DIR:-$HOME/scense_app}"
VENV="${DEPLOY_DIR}/venv"
APP_PORT="${APP_PORT:-9380}"
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-3306}"
DB_NAME="${DB_NAME:-scense}"
DB_USER="${DB_USER:-test}"
DB_PASSWORD="${DB_PASSWORD:-test123}"
GIT_HTTPS="https://git.opencomputing.cn/yumoqing"
FOUNDATION_PKGS="apppublic sqlor ahserver rbac xls2ddl appbase bricks-for-python bricks"
BUSINESS_MODULES="world scene entity script_engine world_snapshot world_sync scense_demo scense_game scense_drag scense_runtime"
APP_SRC_DIR="$(cd "$(dirname "$0")" && pwd)"
mkdir -p "${DEPLOY_DIR}"
export PATH="${VENV}/bin:${PATH}"
echo "==> [0/9] venv"
if [ ! -x "${VENV}/bin/python" ]; then
python3 -m venv "${VENV}"
fi
"${VENV}/bin/pip" install -q --upgrade pip >/dev/null 2>&1 || true
"${VENV}/bin/pip" install -q wheel >/dev/null 2>&1 || true
# 1) 安装模块包
if [ -d "$SAGE_ROOT/py3/bin/pip" ]; then
"$SAGE_ROOT/py3/bin/pip" install -e . || true
fi
# 2) 生成 DDLmodels/*.json -> mysql.ddl.sql
if [ -d models ]; then
(cd "$SAGE_ROOT" && ./py3/bin/python -m xls2ddl.json2ddl mysql "$SCRIPT_DIR/models" > "$SCRIPT_DIR/mysql.ddl.sql" 2>/dev/null) || true
fi
# 3) 生成 CRUD UIjson/*.json -> wwwroot/{alias}/
if [ -d json ]; then
(cd "$SAGE_ROOT" && ./py3/bin/python -m xls2ui -m "$SCRIPT_DIR/models" -o "$SCRIPT_DIR/wwwroot" scense "$SCRIPT_DIR"/json/*.json) || true
fi
# 4) 软链接 wwwroot 到 Sage
mkdir -p "$SAGE_ROOT/wwwroot/scense"
for item in "$SCRIPT_DIR"/wwwroot/*; do
[ -e "$item" ] || continue
name="$(basename "$item")"
ln -sfn "$item" "$SAGE_ROOT/wwwroot/scense/$name"
echo "==> [1/9] clone modules into pkgs/ (git clone only, no mv)"
cd "${DEPLOY_DIR}"
mkdir -p pkgs
for m in ${FOUNDATION_PKGS} ${BUSINESS_MODULES}; do
if [ ! -d "pkgs/${m}/.git" ]; then
rm -rf "pkgs/${m}"
git clone -q "${GIT_HTTPS}/${m}.git" "pkgs/${m}" || { echo "ERROR: clone ${m} failed" >&2; exit 1; }
echo " cloned ${m}"
else
git -C "pkgs/${m}" pull -q --ff-only || echo "WARN: pull ${m} failed, using existing"
fi
done
echo "scense build done. SAGE_ROOT=$SAGE_ROOT"
echo "==> [2/9] pip install all modules"
for m in ${FOUNDATION_PKGS} ${BUSINESS_MODULES}; do
if [ -f "pkgs/${m}/pyproject.toml" ] || [ -f "pkgs/${m}/setup.py" ] || [ -f "pkgs/${m}/setup.cfg" ]; then
"${VENV}/bin/pip" install -q -e "pkgs/${m}" || { echo "ERROR: pip install ${m} failed" >&2; exit 1; }
echo " installed ${m}"
else
echo "WARN: ${m} has no packaging metadata, skipped pip install"
fi
done
echo "==> [3/9] bricks frontend build + symlink"
# dist/ 不入 gitgitignoreclone 后必须本地构建;内层 build.sh 用 cat 拼接,无需 node
mkdir -p pkgs/bricks/dist
if [ ! -f "pkgs/bricks/dist/bricks.js" ]; then
(cd pkgs/bricks/bricks && bash build.sh) || { echo "ERROR: bricks build failed" >&2; exit 1; }
fi
[ -s "pkgs/bricks/dist/bricks.js" ] || { echo "ERROR: bricks.js missing/empty after build" >&2; exit 1; }
mkdir -p wwwroot
ln -sfn "../pkgs/bricks/dist" "wwwroot/bricks"
echo "==> [4/9] database + foundation/initial tables + seed"
mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" \
-e "CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" \
|| { echo "ERROR: create database failed" >&2; exit 1; }
mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \
< "${APP_SRC_DIR}/scripts/ddl.sql" || { echo "ERROR: ddl.sql failed" >&2; exit 1; }
# 种子:默认机构/角色/权限/超管(全新库无此数据则一切请求 401
mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \
< "${APP_SRC_DIR}/scripts/seed.sql" || { echo "ERROR: seed.sql failed" >&2; exit 1; }
echo "==> [5/9] i18n merge"
mkdir -p wwwroot/i18n/zh wwwroot/i18n/en
"${VENV}/bin/python" - <<'PY'
import json, os
ok = 0
for lang in ("zh", "en"):
merged = {}
for m in ("apppublic","sqlor","ahserver","appbase","rbac","world","scene","entity","script_engine","world_snapshot","world_sync","scense_demo","scense_game","scense_drag","scense_runtime"):
p = f"pkgs/{m}/i18n/{lang}/msg.txt"
if os.path.isfile(p):
for line in open(p, encoding="utf-8"):
line = line.rstrip("\n")
if "=" in line:
k, v = line.split("=", 1)
merged[k.strip()] = v.strip()
out = f"wwwroot/i18n/{lang}/i18n.json"
json.dump(merged, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
ok += len(merged)
print(f"i18n merged: {ok} keys")
PY
echo "==> [6/9] per-module install: ① json2ddl建表 ② json/build.sh ③ wwwroot软链 ④ 数据导入"
for m in ${BUSINESS_MODULES}; do
echo " -- ${m}"
# ① 建表:模块 models 是唯一事实源json2ddl 输出 DROP+CREATE每次部署权威重建
(cd "pkgs/${m}/models" && "${VENV}/bin/json2ddl" mysql .) > "/tmp/${m}_ddl.sql" \
|| { echo "ERROR: ${m} json2ddl failed" >&2; exit 1; }
[ -s "/tmp/${m}_ddl.sql" ] || { echo "ERROR: ${m} DDL empty" >&2; exit 1; }
mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \
< "/tmp/${m}_ddl.sql" || { echo "ERROR: ${m} create tables failed" >&2; exit 1; }
# ② CRUD 页面(模块仓库自带 json/build.sh
(cd "pkgs/${m}/json" && bash build.sh) || { echo "ERROR: ${m} json/build.sh failed" >&2; exit 1; }
# ③ wwwroot 软链(非 cp
ln -sfn "../pkgs/${m}/wwwroot" "wwwroot/${m}"
# ④ 初始化数据(有 data/*.xlsx 才导)
if ls "pkgs/${m}/data/"*.xlsx >/dev/null 2>&1; then
(cd "pkgs/${m}/data" && for f in *.xlsx; do "${VENV}/bin/dbloader" "${DEPLOY_DIR}" "${DB_NAME}" "$f"; done) \
|| { echo "ERROR: ${m} dbloader failed" >&2; exit 1; }
fi
done
# 基础模块的 wwwroot 也要软链rbac 登录页、appbase 页面),否则登录页 404
for m in appbase rbac; do
if [ -d "pkgs/${m}/wwwroot" ]; then
ln -sfn "../pkgs/${m}/wwwroot" "wwwroot/${m}"
fi
done
echo "==> [7/9] sync app code + runtime dirs"
for d in app conf scripts wwwroot; do
if [ -d "${APP_SRC_DIR}/${d}" ] && [ "$(realpath "${APP_SRC_DIR}/${d}")" != "$(realpath "${DEPLOY_DIR}/${d}")" ]; then
mkdir -p "${DEPLOY_DIR}/${d}"
cp -a "${APP_SRC_DIR}/${d}/." "${DEPLOY_DIR}/${d}/"
fi
done
mkdir -p logs files
echo "==> [8/9] restart service"
bash "${APP_SRC_DIR}/stop.sh" || true
sleep 1
bash "${APP_SRC_DIR}/start.sh"
echo "==> [9/9] health check: GET /healthz"
ok=""
code=""
for i in $(seq 1 15); do
code=$(curl -s -o /tmp/scense_healthz.json -w "%{http_code}" "http://127.0.0.1:${APP_PORT}/healthz" || true)
if [ "${code}" = "200" ]; then ok=1; break; fi
sleep 2
done
if [ -z "${ok}" ]; then
echo "ERROR: /healthz not 200 after 30s (last=${code:-000}); logs/scense.log tail:" >&2
tail -30 logs/scense.log 2>/dev/null >&2 || true
exit 1
fi
echo "healthz: $(cat /tmp/scense_healthz.json)"
echo "==> deploy OK: app=scense port=${APP_PORT} healthz=200"

View File

@ -1,5 +1,10 @@
{
"password_key": "${PASSWORD_KEY}",
"app": {
"name": "scense",
"port": 9380,
"title": "唤景平台世界模式"
},
"password_key": "scense_test_env_key",
"logger": {
"name": "scense",
"level": "INFO",
@ -10,35 +15,71 @@
"scense": {
"driver": "mysql",
"kwargs": {
"host": "${MYSQL_HOST}",
"port": "${MYSQL_PORT}",
"user": "${MYSQL_USER}",
"password": "${MYSQL_PASSWORD}",
"database": "${MYSQL_DATABASE}",
"charset": "utf8mb4"
"host": "localhost",
"port": 3306,
"user": "test",
"password": "Kj2wTl+LYpPtDSk3B01U7w==",
"charset": "utf8mb4",
"db": "scense"
}
}
},
"website": {
"port": "${SERVER_PORT}",
"paths": {
"/": "$[workdir]$/wwwroot"
},
"port": 9380,
"root": "$[workdir]$/wwwroot",
"processors": [
[".tmpl", "tmpl"],
[".ui", "bui"],
[".dspy", "dspy"],
[".wss", "ws"]
[
".tmpl",
"tmpl"
],
[
".ui",
"bui"
],
[
".dspy",
"dspy"
],
[
".wss",
"ws"
]
],
"indexes": ["index.ui", "index.html"],
"session": {
"driver": "redis",
"kwargs": {
"host": "${REDIS_HOST}",
"port": "${REDIS_PORT}"
},
"session_max_time": 86400,
"session_issue_time": 3600
}
}
}
"indexes": [
"index.ui",
"index.html"
],
"session_max_time": 28800,
"session_issue_time": 7200,
"paths": [
[
"$[workdir]$/wwwroot",
""
]
],
"startswiths": [
{
"leading": "/i18n_getmsgs",
"registerfunction": "i18n"
}
]
},
"modules": [
"apppublic",
"sqlor",
"ahserver",
"appbase",
"rbac",
"world",
"scene",
"entity",
"script_engine",
"world_snapshot",
"world_sync",
"scense_demo",
"scense_game",
"scense_drag",
"scense_runtime"
],
"wwwroot": "wwwroot"
}

View File

@ -1,62 +1,81 @@
{
"widgettype": "HBox",
"id": "root",
"options": {
"css": "filler",
"gap": "0px"
},
"id": "app",
"widgettype": "VBox",
"options": {"width": "100%", "height": "100%", "padding": "0"},
"subwidgets": [
{
"widgettype": "VBox",
"id": "sidebar",
"widgettype": "HBox",
"options": {
"width": "220px",
"bgcolor": "#1f2937",
"padding": "12px"
"width": "100%", "cheight": 2, "bgcolor": "#1e293b",
"padding": "0 16px", "alignItems": "center", "gap": "12px"
},
"subwidgets": [
{
"widgettype": "Text",
"id": "logo",
"options": {
"otext": "元景平台",
"cfontsize": 1.2,
"color": "#ffffff",
"halign": "left",
"css": "filler"
}
"options": {"text": "唤景平台 · 世界模式", "cfontsize": 1.3, "color": "#f8fafc"}
},
{"widgettype": "Filler"},
{
"widgettype": "Menu",
"id": "main_menu",
"options": {
"menuitem_css": "menuitem",
"items": [
{"name": "world", "label": "世界管理", "url": "{{entire_url('/world/index.ui')}}", "target": "app.main_content"},
{"name": "scene", "label": "场景管理", "url": "{{entire_url('/scene/index.ui')}}", "target": "app.main_content"},
{"name": "entity", "label": "实体管理", "url": "{{entire_url('/entity/index.ui')}}", "target": "app.main_content"},
{"name": "script_engine", "label": "逻辑脚本", "url": "{{entire_url('/script_engine/index.ui')}}", "target": "app.main_content"},
{"name": "world_snapshot", "label": "世界快照", "url": "{{entire_url('/world_snapshot/index.ui')}}", "target": "app.main_content"},
{"name": "world_sync", "label": "世界同步", "url": "{{entire_url('/world_sync/index.ui')}}", "target": "app.main_content"}
]
}
"widgettype": "Text",
"options": {"text": "测试环境", "cfontsize": 1.0, "color": "#94a3b8"}
}
]
},
{
"widgettype": "VScrollPanel",
"id": "main_content",
"options": {
"css": "filler"
},
"widgettype": "HBox",
"options": {"width": "100%", "height": "100%", "padding": "0"},
"subwidgets": [
{
"widgettype": "Text",
"id": "welcome",
"widgettype": "VBox",
"options": {"width": "220px", "height": "100%", "bgcolor": "#0f172a", "padding": "8px"},
"subwidgets": [
{
"widgettype": "Menu",
"id": "sidebar_menu",
"options": {
"width": "100%",
"height": "100%",
"target": "app.main_content",
"items": [
{"name": "world", "label": "世界", "icon": "", "script": "var d={name:'world',label:'世界',content:{widgettype:'urlwidget',options:{url:'/world/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='world'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'world'}})"},
{"name": "scene", "label": "场景", "icon": "", "script": "var d={name:'scene',label:'场景',content:{widgettype:'urlwidget',options:{url:'/scene/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='scene'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'scene'}})"},
{"name": "entity", "label": "实体", "icon": "", "script": "var d={name:'entity',label:'实体',content:{widgettype:'urlwidget',options:{url:'/entity/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='entity'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'entity'}})"},
{"name": "script_engine", "label": "脚本引擎", "icon": "", "script": "var d={name:'script_engine',label:'脚本引擎',content:{widgettype:'urlwidget',options:{url:'/script_engine/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='script_engine'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'script_engine'}})"},
{"name": "world_snapshot", "label": "世界快照", "icon": "", "script": "var d={name:'world_snapshot',label:'世界快照',content:{widgettype:'urlwidget',options:{url:'/world_snapshot/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='world_snapshot'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'world_snapshot'}})"},
{"name": "world_sync", "label": "世界同步", "icon": "", "script": "var d={name:'world_sync',label:'世界同步',content:{widgettype:'urlwidget',options:{url:'/world_sync/index.ui'}}};if(!this.opts.items.find(function(i){return i.name==='world_sync'})){this.opts.items.push(d);this.add_tab(d)}this.show_tabcontent({params:{name:'world_sync'}})"}
]
}
}
]
},
{
"widgettype": "TabPanel",
"id": "main_content",
"options": {
"otext": "欢迎使用元景平台",
"cfontsize": 1.5,
"halign": "left"
"css": "filler",
"width": "100%",
"height": "100%",
"tab_pos": "top",
"items": [
{
"name": "home",
"label": "首页",
"content": {
"widgettype": "VBox",
"options": {"css": "filler", "padding": "24px", "gap": "12px"},
"subwidgets": [
{
"widgettype": "Text",
"options": {"text": "欢迎使用唤景平台(世界模式)", "cfontsize": 1.6}
},
{
"widgettype": "Text",
"options": {"text": "左侧菜单选择模块:世界 / 场景 / 实体 / 脚本引擎 / 世界快照 / 世界同步", "cfontsize": 1.2, "color": "#64748b"}
}
]
}
}
]
}
}
]