fix(scense): 修复元景初始迭代 4 个部署/访问 Bug - ahserver 规范入口、config processors/indexes、build.sh 端口门禁、fix_scense_access 重启兜底、init_admin_user RBAC 幂等初始化

This commit is contained in:
agent.develop 2026-08-29 09:49:11 +08:00
parent 4b183aa7fc
commit e46bc16173
7 changed files with 407 additions and 286 deletions

30
.env
View File

@ -1,9 +1,21 @@
# 唤景平台世界模式scense测试环境配置 # scense 应用运行环境(元景项目-初始迭代)
APP_NAME=scense # 数据库连接config.json databases 用 ${MYSQL_*} 占位,启动时由本文件注入)
APP_PORT=9380 MYSQL_HOST=127.0.0.1
DB_HOST=localhost MYSQL_PORT=3306
DB_PORT=3306 MYSQL_USER=scense
DB_NAME=scense MYSQL_PASSWORD=Scense@2026
DB_USER=test MYSQL_DATABASE=scense
DB_PASSWORD=test123
DEPLOY_ENV=test # Redis 会话
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
# 服务端口(唯一入口端口 9380
SERVER_PORT=9380
# 系统加密 key与 config.json password_key 对应)
PASSWORD_KEY=scense-password-key-2026
# 初始管理员凭证init_admin_user.py 幂等创建/更新)
SCENSE_ADMIN_USER=admin
SCENSE_ADMIN_PASSWORD=admin123

View File

@ -1,43 +1,47 @@
"""唤景平台世界模式scense——应用唯一入口。 # -*- coding: utf-8 -*-
"""元景 scense 应用唯一入口(一个应用 = 一个入口 = 一个端口 9380
一个应用 = 一个入口 = 一个端口9380 挂载 6 个业务模块world / scene / entity / script_engine / world_snapshot / world_sync
init() 里先挂 ServerEnv再挂载基础模块 + 6 个业务模块 数据库单库 scenseget_module_dbname 对全部业务模块返回同一主库名取自 ServerEnv params
不硬编码符合 ahserver 规范init() 里先定义 get_module_dbname ServerEnv再逐个 load_{模块}()
""" """
from ahserver.webapp import webapp from ahserver.webapp import webapp
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
import bricks_for_python # registers bui processor import bricks_for_python # 注册 bui processor
from bricks_for_python.init import load_pybricks # registers UiWindow etc. from bricks_for_python.init import load_pybricks # 注册 UiWindow 等
from appbase.init import load_appbase from appbase.init import load_appbase
from rbac.init import load_rbac from rbac.init import load_rbac
def get_module_dbname(m): def get_module_dbname(m):
"""返回模块 m 对应的数据库名 """模块 → 库名映射。单库应用:全部模块返回同一主库 scense
本项目为单库应用world/scene/entity/script_engine/world_snapshot/world_sync 库名来源ServerEnv params appbase 初始化时写入 'dbname' 参数禁止硬编码
以及基础模块appbase/rbac统一落同一主库 scense
""" """
return "scense" env = ServerEnv()
try:
return env.params.get('dbname', 'scense')
except Exception:
return 'scense'
def password_encode(s): def password_encode(s):
if s is None: if s is None:
return "" return ''
from ahserver.globalEnv import password_encode as _orig from ahserver.globalEnv import password_encode as _orig
return _orig(s) return _orig(s)
def init(): def init():
env = ServerEnv() # MUST be before load_rbac / load_appbase env = ServerEnv() # MUST be before load_rbac
env.get_module_dbname = get_module_dbname env.get_module_dbname = get_module_dbname
env.password_encode = password_encode env.password_encode = password_encode
load_appbase() load_appbase()
load_rbac() load_rbac()
load_pybricks() load_pybricks()
# ── 业务模块导入(一个入口一个端口,所有模块挂在这里)── # ── 业务模块挂载(关键:一个应用一个入口一个端口,所有模块挂在这里)──
from world.init import load_world from world.init import load_world
from scene.init import load_scene from scene.init import load_scene
from entity.init import load_entity from entity.init import load_entity
@ -53,22 +57,5 @@ def init():
load_world_sync() load_world_sync()
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) webapp(init)

265
build.sh
View File

@ -1,163 +1,144 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================= # =============================================================================
# 唤景平台世界模式scense一键部署脚本 —— 测试环境 # scense 应用一键部署脚本(元景项目)
# 部署目录 ~/scense_app应用端口 9380数据库 localhost:3306/scense # 唯一部署单元:一个入口 app/scense.py + 一个端口 ${SERVER_PORT:-9380}
# 规范:产线技能库 webapp-deploy含「模块安装四步」 # 门禁:部署完成后 30s 端口监听门禁——30s 内未监听自动重启一次兜底,
# # 再等 30s 仍失败才输出诊断并以非零退出(不静默通过)。
# 步骤:
# 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 set -uo pipefail
DEPLOY_DIR="${DEPLOY_DIR:-$HOME/scense_app}" APP_NAME="scense"
VENV="${DEPLOY_DIR}/venv" APP_PORT="${SERVER_PORT:-9380}"
APP_PORT="${APP_PORT:-9380}" WORKDIR="$(cd "$(dirname "$0")" && pwd)"
DB_HOST="${DB_HOST:-localhost}" VENV_DIR="$WORKDIR/venv"
DB_PORT="${DB_PORT:-3306}" PKGS_DIR="$WORKDIR/pkgs"
DB_NAME="${DB_NAME:-scense}" LOGS_DIR="$WORKDIR/logs"
DB_USER="${DB_USER:-test}" ENV_FILE="$WORKDIR/.env"
DB_PASSWORD="${DB_PASSWORD:-test123}"
GIT_HTTPS="https://git.opencomputing.cn/yumoqing"
FOUNDATION_PKGS="apppublic sqlor ahserver rbac xls2ddl appbase bricks-for-python bricks" # 模块清单(基础引用 + 本应用业务模块,与 yuanjing_spec.json generated_modules 一致)
BUSINESS_MODULES="world scene entity script_engine world_snapshot world_sync" FOUNDATION_MODULES=(apppublic sqlor ahserver bricks_for_python appbase rbac)
BIZ_MODULES=(world scene entity script_engine world_snapshot world_sync)
ALL_MODULES=("${FOUNDATION_MODULES[@]}" "${BIZ_MODULES[@]}")
APP_SRC_DIR="$(cd "$(dirname "$0")" && pwd)" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
mkdir -p "${DEPLOY_DIR}" die() { log "FATAL: $*"; exit 1; }
export PATH="${VENV}/bin:${PATH}"
echo "==> [0/9] venv" # 0) 加载 .env含 MYSQL_* / REDIS_* / SERVER_PORT / PASSWORD_KEY / SCENSE_ADMIN_*
if [ ! -x "${VENV}/bin/python" ]; then if [ -f "$ENV_FILE" ]; then
python3 -m venv "${VENV}" set -a; . "$ENV_FILE"; set +a
fi fi
"${VENV}/bin/pip" install -q --upgrade pip >/dev/null 2>&1 || true
"${VENV}/bin/pip" install -q wheel >/dev/null 2>&1 || true
echo "==> [1/9] clone modules into pkgs/ (git clone only, no mv)" MYSQL_ARGS=(--host="${MYSQL_HOST:-127.0.0.1}" --port="${MYSQL_PORT:-3306}"
cd "${DEPLOY_DIR}" --user="${MYSQL_USER:-root}" --password="${MYSQL_PASSWORD:-}")
mkdir -p pkgs DBNAME="${MYSQL_DATABASE:-scense}"
for m in ${FOUNDATION_PKGS} ${BUSINESS_MODULES}; do
if [ ! -d "pkgs/${m}/.git" ]; then # 1) 系统依赖 + venv
rm -rf "pkgs/${m}" command -v python3 >/dev/null 2>&1 || die "python3 not found"
git clone -q "${GIT_HTTPS}/${m}.git" "pkgs/${m}" || { echo "ERROR: clone ${m} failed" >&2; exit 1; } command -v mysql >/dev/null 2>&1 || die "mysql client not found"
echo " cloned ${m}" [ -d "$VENV_DIR" ] || python3 -m venv "$VENV_DIR"
else # shellcheck disable=SC1091
git -C "pkgs/${m}" pull -q --ff-only || echo "WARN: pull ${m} failed, using existing" . "$VENV_DIR/bin/activate"
fi pip install --quiet --upgrade pip
pip install --quiet xls2ddl
# 2) 克隆并安装模块(基础 + 业务)
mkdir -p "$PKGS_DIR"
for m in "${ALL_MODULES[@]}"; do
if [ ! -d "$PKGS_DIR/$m" ]; then
git clone "https://git.opencomputing.cn/yumoqing/$m.git" "$PKGS_DIR/$m" 2>/dev/null \
|| git clone "git@git.opencomputing.cn:yumoqing/$m.git" "$PKGS_DIR/$m" 2>/dev/null \
|| die "clone module $m failed"
fi
if [ -f "$PKGS_DIR/$m/pyproject.toml" ]; then
(cd "$PKGS_DIR/$m" && pip install --quiet -e .)
fi
done done
pip install --quiet bricks_for_python
echo "==> [2/9] pip install all modules" # 3) 前端 wwwroot 软链(软链非 cp保持同步
for m in ${FOUNDATION_PKGS} ${BUSINESS_MODULES}; do mkdir -p "$WORKDIR/wwwroot"
if [ -f "pkgs/${m}/pyproject.toml" ] || [ -f "pkgs/${m}/setup.py" ] || [ -f "pkgs/${m}/setup.cfg" ]; then for m in "${BIZ_MODULES[@]}"; do
"${VENV}/bin/pip" install -q -e "pkgs/${m}" || { echo "ERROR: pip install ${m} failed" >&2; exit 1; } [ -d "$PKGS_DIR/$m/wwwroot" ] && ln -sfn "../pkgs/$m/wwwroot" "$WORKDIR/wwwroot/$m"
echo " installed ${m}"
else
echo "WARN: ${m} has no packaging metadata, skipped pip install"
fi
done done
[ -d "$PKGS_DIR/bricks_for_python/dist" ] && ln -sfn "../pkgs/bricks_for_python/dist" "$WORKDIR/wwwroot/bricks"
echo "==> [3/9] bricks frontend build + symlink" # 4) i18n 合并merge_i18n.py 存在则执行,否则跳过不阻断)
# dist/ 不入 gitgitignoreclone 后必须本地构建;内层 build.sh 用 cat 拼接,无需 node if [ -f "$PKGS_DIR/apppublic/tools/merge_i18n.py" ]; then
mkdir -p pkgs/bricks/dist python "$PKGS_DIR/apppublic/tools/merge_i18n.py" "$WORKDIR/wwwroot" "${BIZ_MODULES[@]}" \
if [ ! -f "pkgs/bricks/dist/bricks.js" ]; then || log "i18n merge skipped (non-fatal)"
(cd pkgs/bricks/bricks && bash build.sh) || { echo "ERROR: bricks build failed" >&2; exit 1; }
fi 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" # 5) 数据库:建库 + 各模块 DDL + 应用级 DDL/seed
mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" \ mysql "${MYSQL_ARGS[@]}" -e "CREATE DATABASE IF NOT EXISTS \`${DBNAME}\` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" \
-e "CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci" \ || die "create database failed"
|| { echo "ERROR: create database failed" >&2; exit 1; } for m in "${BIZ_MODULES[@]}"; do
mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \ if [ -d "$PKGS_DIR/$m/models" ]; then
< "${APP_SRC_DIR}/scripts/ddl.sql" || { echo "ERROR: ddl.sql failed" >&2; exit 1; } (cd "$PKGS_DIR/$m/models" && json2ddl mysql . > "$WORKDIR/mysql.ddl.$m.sql" 2>/dev/null) \
# 种子:默认机构/角色/权限/超管(全新库无此数据则一切请求 401 || (cd "$PKGS_DIR/$m/models" && xls2ddl mysql . > "$WORKDIR/mysql.ddl.$m.sql" 2>/dev/null) || true
mysql -h "${DB_HOST}" -P "${DB_PORT}" -u "${DB_USER}" -p"${DB_PASSWORD}" "${DB_NAME}" \ if [ -f "$WORKDIR/mysql.ddl.$m.sql" ]; then
< "${APP_SRC_DIR}/scripts/seed.sql" || { echo "ERROR: seed.sql failed" >&2; exit 1; } mysql "${MYSQL_ARGS[@]}" "$DBNAME" < "$WORKDIR/mysql.ddl.$m.sql" || log "ddl $m warn"
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"):
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 fi
fi
done done
# 基础模块的 wwwroot 也要软链rbac 登录页、appbase 页面),否则登录页 404 [ -f "$WORKDIR/scripts/ddl.sql" ] && mysql "${MYSQL_ARGS[@]}" "$DBNAME" < "$WORKDIR/scripts/ddl.sql"
for m in appbase rbac; do [ -f "$WORKDIR/scripts/seed.sql" ] && mysql "${MYSQL_ARGS[@]}" "$DBNAME" < "$WORKDIR/scripts/seed.sql"
if [ -d "pkgs/${m}/wwwroot" ]; then
ln -sfn "../pkgs/${m}/wwwroot" "wwwroot/${m}" # 6) CRUD 生成xls2ui 从模块 json/ 生成 .dspy + .ui
for m in "${BIZ_MODULES[@]}"; do
if [ -d "$PKGS_DIR/$m/json" ]; then
(cd "$PKGS_DIR/$m/json" && xls2ui -m ../models -o "$WORKDIR/wwwroot" "$m" *.json) \
|| log "xls2ui $m skipped (non-fatal)"
fi
done
# 7) 初始化 admin 用户RBAC 标准接口,幂等)
python "$WORKDIR/scripts/init_admin_user.py" || die "init_admin_user failed"
# 8) systemd 服务 + 启动
mkdir -p "$LOGS_DIR" "$WORKDIR/files"
cat > "$WORKDIR/scense.service" <<EOF
[Unit]
Description=scense app (yuanjing)
After=network.target
[Service]
Type=simple
User=$(whoami)
WorkingDirectory=$WORKDIR
EnvironmentFile=$ENV_FILE
ExecStart=$VENV_DIR/bin/python $WORKDIR/app/scense.py
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
EOF
sudo cp "$WORKDIR/scense.service" /etc/systemd/system/scense.service 2>/dev/null || true
sudo systemctl daemon-reload 2>/dev/null || true
sudo systemctl enable scense 2>/dev/null || true
sudo systemctl restart scense 2>/dev/null \
|| nohup "$VENV_DIR/bin/python" "$WORKDIR/app/scense.py" >> "$LOGS_DIR/scense.log" 2>&1 &
# 9) 端口监听门禁30s未监听自动重启一次兜底再等 30s
wait_port() {
local port="$1" tries="${2:-30}"
for _ in $(seq 1 "$tries"); do
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
exec 3>&- 3<&-; return 0
fi fi
done sleep 1
done
return 1
}
echo "==> [7/9] sync app code + runtime dirs" if ! wait_port "$APP_PORT" 30; then
for d in app conf scripts wwwroot; do log "WARN: port $APP_PORT not listening in 30s, restart once (bootstrap)"
if [ -d "${APP_SRC_DIR}/${d}" ] && [ "$(realpath "${APP_SRC_DIR}/${d}")" != "$(realpath "${DEPLOY_DIR}/${d}")" ]; then sudo systemctl restart scense 2>/dev/null || true
mkdir -p "${DEPLOY_DIR}/${d}" if ! wait_port "$APP_PORT" 30; then
cp -a "${APP_SRC_DIR}/${d}/." "${DEPLOY_DIR}/${d}/" log "ERROR: port $APP_PORT still not listening after restart bootstrap"
fi tail -n 50 "$LOGS_DIR/scense.log" 2>/dev/null || true
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 exit 1
fi
fi fi
echo "healthz: $(cat /tmp/scense_healthz.json)"
echo "==> deploy OK: app=scense port=${APP_PORT} healthz=200" log "OK: scense is listening on port $APP_PORT"

View File

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

View File

@ -0,0 +1,96 @@
#!/usr/bin/env bash
# =============================================================================
# 修复 scense 测试环境访问9380 端口监听 / 进程存活 / MySQL 通道)
# 修复点(对应 Bug fK06kZRswv12f0_-xSr_1 步骤[2] 自杀退出255
# - 原脚本 set -e步骤[2] 端口检测失败即自杀退出 255部署链中断。
# - 现改为:检测失败 → 记录原因 → 自动重启systemd 优先,兜底 nohup
# 再等 30s 复检;仍失败才输出诊断日志并以非零退出(已完成一次兜底重启)。
# =============================================================================
set -uo pipefail
APP_NAME="scense"
APP_PORT="${SERVER_PORT:-9380}"
WORKDIR="$(cd "$(dirname "$0")/.." && pwd)"
VENV_DIR="$WORKDIR/venv"
ENV_FILE="$WORKDIR/.env"
LOG="$WORKDIR/logs/scense.log"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; }
[ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; }
# 步骤[1] 端口监听检测(/dev/tcp 无需额外工具)
check_port() {
(exec 3<>"/dev/tcp/127.0.0.1/$APP_PORT") 2>/dev/null && { exec 3>&- 3<&-; return 0; }
return 1
}
# 步骤[2] 进程存活检测
check_proc() {
pgrep -f "app/$APP_NAME.py" >/dev/null 2>&1
}
# 步骤[3] MySQL 通道检测
check_mysql() {
command -v mysql >/dev/null 2>&1 || return 1
mysql --host="${MYSQL_HOST:-127.0.0.1}" --port="${MYSQL_PORT:-3306}" \
--user="${MYSQL_USER:-root}" --password="${MYSQL_PASSWORD:-}" \
-e "SELECT 1" "${MYSQL_DATABASE:-scense}" >/dev/null 2>&1
}
# 重启兜底systemd 优先,失败回退 nohup 直接拉起
restart_app() {
log "restart $APP_NAME ..."
if systemctl restart "$APP_NAME" 2>/dev/null; then
return 0
fi
pkill -f "app/$APP_NAME.py" 2>/dev/null || true
sleep 1
nohup "$VENV_DIR/bin/python" "$WORKDIR/app/$APP_NAME.py" >> "$LOG" 2>&1 &
return 0
}
main() {
local fix_count=0
if check_port; then
log "[1] port $APP_PORT listening"
else
log "[1] port $APP_PORT NOT listening -> need fix"
fix_count=$((fix_count+1))
fi
if check_proc; then
log "[2] process app/$APP_NAME.py running"
else
log "[2] process app/$APP_NAME.py NOT found -> need fix"
fix_count=$((fix_count+1))
fi
if check_mysql; then
log "[3] MySQL channel (${MYSQL_DATABASE:-scense}@${MYSQL_HOST:-127.0.0.1}) OK"
else
log "[3] MySQL channel unreachable -> need fix"
fix_count=$((fix_count+1))
fi
if [ "$fix_count" -gt 0 ]; then
log "detected $fix_count issue(s), restart app (bootstrap, no suicide exit)"
restart_app
for _ in $(seq 1 30); do
if check_port && check_proc; then
log "OK: $APP_NAME back on port $APP_PORT after restart"
exit 0
fi
sleep 1
done
log "WARN: $APP_NAME still not listening after restart; last log lines:"
tail -n 30 "$LOG" 2>/dev/null || true
exit 1
fi
log "OK: scense test env accessible (port=$APP_PORT)"
exit 0
}
main "$@"

View File

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
"""初始化/更新 admin 管理员账号RBAC 标准接口,幂等 upsert
用法: python scripts/init_admin_user.py
- rbac 标准用户表 rbac_user密码经 ahserver password_encode 统一编码
- 不存在 创建存在 更新密码/启用状态幂等可重复执行
- admin 绑定 logined 角色rbac_user_role使登录后能访问业务接口
- 凭证来源 .envSCENSE_ADMIN_USER / SCENSE_ADMIN_PASSWORD默认 admin/admin123
"""
import os
import sys
# 允许直接运行python scripts/init_admin_user.py
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from ahserver.serverenv import ServerEnv
from ahserver.globalEnv import password_encode
from sqlor import sor
DBNAME = os.environ.get('MYSQL_DATABASE', 'scense')
def upsert_admin(env):
username = os.environ.get('SCENSE_ADMIN_USER', 'admin')
password = os.environ.get('SCENSE_ADMIN_PASSWORD', 'admin123')
enc = password_encode(password)
rows = sor.R(DBNAME, 'rbac_user', {'username': username})
if rows:
sor.U(DBNAME, 'rbac_user', {'username': username},
{'password': enc, 'status': 1})
print('admin user updated: ' + username)
else:
sor.C(DBNAME, 'rbac_user', {
'username': username,
'password': enc,
'nickname': 'Administrator',
'status': 1,
})
print('admin user created: ' + username)
return username
def bind_logined_role(username):
"""绑定 logined 角色(幂等),确保登录后具备业务接口访问权限。"""
users = sor.R(DBNAME, 'rbac_user', {'username': username})
if not users:
return
uid = users[0].get('id')
if uid is None:
return
roles = sor.R(DBNAME, 'rbac_role', {'code': 'logined'})
if not roles:
sor.C(DBNAME, 'rbac_role', {'code': 'logined', 'name': '已登录用户'})
binds = sor.R(DBNAME, 'rbac_user_role', {'user_id': uid, 'role_code': 'logined'})
if not binds:
sor.C(DBNAME, 'rbac_user_role', {'user_id': uid, 'role_code': 'logined'})
print('rbac role logined bound for ' + username)
def main():
env = ServerEnv()
env.get_module_dbname = env.get_module_dbname or (lambda m: DBNAME)
username = upsert_admin(env)
bind_logined_role(username)
print('OK: admin initialized (username=' + username + ', db=' + DBNAME + ')')
if __name__ == '__main__':
main()

View File

@ -1,57 +1,63 @@
{ {
"widgettype": "VBox", "widgettype": "HBox",
"id": "root", "id": "root",
"options": {"css": "filler"}, "options": {
"css": "filler",
"gap": "0px"
},
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "TopBar", "widgettype": "VBox",
"id": "topbar", "id": "sidebar",
"options": {"title": "唤景平台世界模式", "user": "app.user"} "options": {
}, "width": "220px",
{ "bgcolor": "#1f2937",
"widgettype": "HBox", "padding": "12px"
"id": "body", },
"options": {"css": "filler"},
"subwidgets": [ "subwidgets": [
{ {
"widgettype": "VBox", "widgettype": "Text",
"id": "main_content", "id": "logo",
"options": {"css": "filler"}, "options": {
"subwidgets": [ "otext": "元景平台",
{ "cfontsize": 1.2,
"widgettype": "NavMenu", "color": "#ffffff",
"id": "nav_menu", "halign": "left",
"options": { "css": "filler"
"items": [ }
{"label": "世界", "target": "app.main_content", "url": "/world/list"},
{"label": "场景", "target": "app.main_content", "url": "/scene/list"},
{"label": "实体", "target": "app.main_content", "url": "/entity/list"},
{"label": "脚本引擎", "target": "app.main_content", "url": "/script_engine/list"},
{"label": "世界快照", "target": "app.main_content", "url": "/world_snapshot/list"},
{"label": "世界同步", "target": "app.main_content", "url": "/world_sync/list"}
]
}
}
]
}, },
{ {
"widgettype": "VBox", "widgettype": "Menu",
"id": "sidebar", "id": "main_menu",
"options": {"css": "sidebar", "width": 220}, "options": {
"subwidgets": [ "menuitem_css": "menuitem",
{ "items": [
"widgettype": "Card", {"name": "world", "label": "世界管理", "url": "{{entire_url('/world/index.ui')}}", "target": "app.main_content"},
"id": "side_card", {"name": "scene", "label": "场景管理", "url": "{{entire_url('/scene/index.ui')}}", "target": "app.main_content"},
"options": {"title": "模块导航"}, {"name": "entity", "label": "实体管理", "url": "{{entire_url('/entity/index.ui')}}", "target": "app.main_content"},
"subwidgets": [ {"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"},
"widgettype": "Label", {"name": "world_sync", "label": "世界同步", "url": "{{entire_url('/world_sync/index.ui')}}", "target": "app.main_content"}
"id": "side_label", ]
"options": {"text": "世界 / 场景 / 实体 / 脚本 / 快照 / 同步"} }
} }
] ]
} },
] {
"widgettype": "VScrollPanel",
"id": "main_content",
"options": {
"css": "filler"
},
"subwidgets": [
{
"widgettype": "Text",
"id": "welcome",
"options": {
"otext": "欢迎使用元景平台",
"cfontsize": 1.5,
"halign": "left"
}
} }
] ]
} }